Unify subordinate lifecycle across parallel calls

Route direct and parallel call_subordinate execution through one reusable, persisted A1/A2 child-context lifecycle.

Keep failed children resumable with reset=false and cover sibling reuse, recursive numbering, persistence, and nested task ownership.
This commit is contained in:
Alessandro 2026-08-19 09:30:50 +02:00
parent cf1bec3ed9
commit a304c7665f
9 changed files with 721 additions and 129 deletions

View file

@ -23,6 +23,7 @@ PARALLEL_WORKER_JOB_KEY = "_parallel_job_id"
PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind"
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id"
@ -53,6 +54,7 @@ class ParallelJob:
tool_name: str
tool_args: dict[str, Any]
kind: JobKind
parent_agent: "Agent | None" = field(default=None, repr=False)
state: JobState = "pending"
created_at: float = field(default_factory=time.time)
started_at: float | None = None
@ -208,6 +210,7 @@ async def start_parallel_jobs(
tool_name=call.tool_name,
tool_args=call.tool_args,
kind=kind,
parent_agent=agent,
)
job_store[job.id] = job
jobs.append(job)
@ -218,6 +221,8 @@ async def start_parallel_jobs(
job.started_at = time.time()
task = DeferredTask(thread_name=THREAD_BACKGROUND)
job.deferred_task = task
if _parallel_worker_kind(agent) == "subordinate" and context.task:
context.task.add_child_task(task)
task.start_task(_run_parallel_job, context.id, job.id)
except Exception as exc:
_finish_job(job, "error", error=str(exc))
@ -410,34 +415,39 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
from agent import AgentContext, AgentContextType, UserMessage
from helpers import message_queue, persist_chat
from agent import AgentContext
from helpers.tool_policy import ensure_tool_allowed
from tools.call_subordinate import _validate_subordinate_profile
from tools.call_subordinate import get_or_create_subordinate, run_subordinate
parent_context = AgentContext.get(parent_context_id)
if not parent_context:
raise ValueError("Parent context not found.")
ensure_tool_allowed(parent_context.agent0, "call_subordinate")
parent_agent = job.parent_agent or parent_context.agent0
ensure_tool_allowed(parent_agent, "call_subordinate")
args = job.tool_args
message = str(args.get("message") or "").strip()
if not message:
raise ValueError("call_subordinate requires `tool_args.message`.")
profile = _validate_subordinate_profile(
parent_context.agent0,
str(args.get("profile") or args.get("agent_profile") or ""),
context_id = str(args.get("context_id") or args.get("agent_id") or "").strip()
reset = args.get("reset", False)
slot = (
job.id
if coerce_bool(reset, False) and not context_id
else "default"
)
attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
attachments = [str(item) for item in attachments]
child_name = _subordinate_context_name(job)
worker_context = AgentContext(
config=_clone_config(parent_context.config, profile=profile),
name=child_name,
type=AgentContextType.USER,
subordinate = get_or_create_subordinate(
parent_agent,
profile=str(args.get("profile") or args.get("agent_profile") or ""),
reset=reset,
context_id=context_id,
name=str(args.get("name") or ""),
message=message,
slot=slot,
)
worker_context = subordinate.context
job.worker_context_id = worker_context.id
if job.deferred_task:
worker_context.task = job.deferred_task
@ -445,30 +455,9 @@ async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob)
worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id)
worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind)
worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id)
worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name)
worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id)
worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name)
_copy_project(parent_context, worker_context)
system_prompt = _subordinate_worker_system_prompt(profile)
message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)")
worker_context.agent0.hist_add_user_message(
UserMessage(
message=message,
attachments=attachments,
system_message=[system_prompt],
)
)
persist_chat.save_tmp_chat(worker_context)
try:
result = await worker_context.agent0.monologue()
worker_context.agent0.history.new_topic()
return result
finally:
persist_chat.save_tmp_chat(worker_context)
return await run_subordinate(parent_agent, subordinate, message, attachments)
async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
@ -711,16 +700,13 @@ def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]:
return data
def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig":
def _clone_config(config: "AgentConfig") -> "AgentConfig":
try:
cloned = replace(
return replace(
config,
knowledge_subdirs=list(config.knowledge_subdirs),
additional=dict(config.additional),
)
if profile:
cloned.profile = profile
return cloned
except Exception:
return config
@ -734,27 +720,3 @@ def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext"
projects.activate_project(worker_context.id, project_name, mark_dirty=False)
except Exception:
pass
def _subordinate_worker_system_prompt(profile: str) -> str:
lines = [
"You are running as an isolated parallel worker for a parent Agent Zero chat.",
"Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.",
]
if profile:
lines.append(f"Act with the `{profile}` profile's expertise and priorities.")
return "\n".join(lines)
def _subordinate_context_name(job: ParallelJob) -> str:
name = str(job.tool_args.get("name") or "").strip()
if name:
return name
message = str(job.tool_args.get("message") or "").strip()
label = _short_label(message)
return label or f"Parallel subordinate {job.index + 1}"
def _short_label(text: str, limit: int = 80) -> str:
compact = " ".join(text.split())
return compact[:limit].rstrip()

View file

@ -26,11 +26,11 @@
- Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored.
- `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
- Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop.
- `call_subordinate` jobs first enforce the parent profile's delegation policy
and validate the requested profile through the sequential delegation owner,
then run in isolated child chat contexts tagged with parent-chat metadata;
they must not be added to the scheduler task list and may use normal child-chat
tools, including `parallel`.
- `call_subordinate` jobs first enforce the actual calling agent's delegation policy, then call the same creation and execution functions as direct delegation in `tools/call_subordinate.py`; this helper does not construct or prompt a second kind of subordinate.
- Fresh parallel sibling calls create distinct `parent.number + 1` child agents. Their job snapshots expose stable `context_id` values that direct or parallel `reset=false` calls can continue after success or failure.
- Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0.
- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
- Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.

View file

@ -1,9 +1,11 @@
### call_subordinate
delegate research or complex subtasks to a specialized agent.
args: `message`, optional `profile`, `reset`
args: `message`, optional `profile`, `reset`, `context_id`
- `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile
- `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue
- `reset`: use json boolean `true` to create a fresh child; use `false` to continue the default child or the supplied `context_id`
- `context_id`: stable child ID returned by an earlier direct or parallel call; use it with `reset: false` to continue that exact child
- `message`: define role, goal, and the concrete task
each caller creates its next agent level: A0 creates A1 children, A1 creates A2 children, and so on
after the subordinate returns, answer from its result directly when it satisfies the user request
do not repeat the same solving work or call extra tools after a sufficient subordinate result
example:

View file

@ -10,7 +10,7 @@ Rules:
- never nest `parallel`
- Never include `document_query` in `tool_calls`; it is too heavy for parallel workers, so call it sequentially.
- Call `response` only as a top-level tool so it ends the message loop; never wrap it inside `parallel.tool_calls`.
- `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task
- `call_subordinate` uses the same child lifecycle here as it does top-level; fresh siblings are next-level agents, and each job's `context_id` can be continued later with `reset: false`
- use `wait: false` only when you will collect results later with `job_ids`
- if extras list running or ready parallel jobs, collect them before final synthesis
- `timeout` only limits how long this call waits; running jobs continue and can be awaited again by `job_ids`

View file

@ -48,6 +48,7 @@ class _FakeContext:
self.id = "ctx"
self.data = {}
self.log = _FakeLog()
self.task = None
def get_data(self, key: str, recursive: bool = True):
return self.data.get(key)
@ -60,14 +61,29 @@ class _FakeAgent:
def __init__(self) -> None:
self.context = _FakeContext()
self.agent_name = "A0"
self.number = 0
class _FakeDeferredTask:
def __init__(self, *, ready: bool = False, alive: bool = True, result=None) -> None:
def __init__(
self,
*,
ready: bool = False,
alive: bool = True,
result=None,
thread_name=None,
) -> None:
self.ready = ready
self.alive = alive
self._result = result
self.killed = 0
self.thread_name = thread_name
self.started = None
self.children = []
def start_task(self, func, *args):
self.started = (func, args)
return self
def is_ready(self):
return self.ready
@ -81,6 +97,12 @@ class _FakeDeferredTask:
def kill(self):
self.killed += 1
self.alive = False
for child in self.children:
child.kill()
self.children = []
def add_child_task(self, task, terminate_thread=False):
self.children.append(task)
def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> None:
@ -138,6 +160,20 @@ def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
def test_subordinate_prompts_share_reusable_tree_contract() -> None:
call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
encoding="utf-8"
)
parallel_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.parallel.md").read_text(
encoding="utf-8"
)
assert "A0 creates A1 children, A1 creates A2 children" in call_prompt
assert "stable child ID" in call_prompt
assert "same child lifecycle here as it does top-level" in parallel_prompt
assert "each job's `context_id`" in parallel_prompt
def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
with pytest.raises(ValueError, match="cannot be nested"):
parallel_tools.normalize_parallel_tool_calls(
@ -475,6 +511,246 @@ async def test_parallel_subordinate_reuses_profile_validation(monkeypatch) -> No
await parallel_tools._run_subordinate_context_job("ctx", job)
@pytest.mark.asyncio
async def test_parallel_subordinates_are_distinct_reusable_a1_children(monkeypatch) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
parent_id = "ctx-parallel-a1-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
async def fake_monologue(agent):
return agent.agent_name
monkeypatch.setattr(Agent, "monologue", fake_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_ids = []
try:
jobs = await parallel_tools.start_parallel_jobs(
parent.agent0,
[
parallel_tools.NormalizedToolCall(
index=0,
tool_name="call_subordinate",
tool_args={"message": "left branch", "reset": True},
),
parallel_tools.NormalizedToolCall(
index=1,
tool_name="call_subordinate",
tool_args={"message": "right branch", "reset": True},
),
],
)
results = await parallel_tools.await_parallel_jobs(
parent.agent0,
[job.id for job in jobs],
timeout=10,
)
child_ids = [result["context_id"] for result in results]
assert [result["state"] for result in results] == ["success", "success"]
assert [result["result"] for result in results] == ["A1", "A1"]
assert len(set(child_ids)) == 2
assert set(parent.agent0.get_data("_subordinates")) == set(child_ids)
for child_id in child_ids:
child = AgentContext.get(child_id)
assert child is not None
assert child.agent0.number == 1
assert child.get_output_data("parent_context_id") == parent.id
assert child.get_output_data("parent_agent_number") == 0
assert child.get_output_data("parent_context_kind") == "subordinate"
finally:
for child_id in child_ids:
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_failed_parallel_subordinate_continues_directly_or_in_parallel(
monkeypatch,
) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
from tools.call_subordinate import Delegation
parent_id = "ctx-parallel-resume-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
calls = {}
async def flaky_monologue(agent):
count = calls.get(agent.context.id, 0) + 1
calls[agent.context.id] = count
if count == 1:
raise RuntimeError("simulated API failure")
return f"{agent.agent_name} continuation {count}"
monkeypatch.setattr(Agent, "monologue", flaky_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_id = ""
try:
failed = parallel_tools.ParallelJob(
id="callsubordin-failed",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "remember ALPHA", "reset": True},
kind="subordinate",
parent_agent=parent.agent0,
)
parallel_tools._jobs_for_context(parent)[failed.id] = failed
await parallel_tools._run_parallel_job(parent.id, failed.id)
child_id = failed.worker_context_id or ""
assert failed.state == "error"
assert failed.error == "simulated API failure"
assert child_id
assert AgentContext.get(child_id).agent0.number == 1 # type: ignore[union-attr]
direct = Delegation(
parent.agent0,
"call_subordinate",
None,
{},
"",
None,
)
direct_result = await direct.execute(
message="continue after the API failure",
context_id=child_id,
reset=False,
)
assert direct_result.message == "A1 continuation 2"
assert direct_result.additional == {"context_id": child_id}
continued = parallel_tools.ParallelJob(
id="callsubordin-continued",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={
"message": "continue once more",
"context_id": child_id,
"reset": False,
},
kind="subordinate",
parent_agent=parent.agent0,
)
parallel_tools._jobs_for_context(parent)[continued.id] = continued
await parallel_tools._run_parallel_job(parent.id, continued.id)
assert continued.state == "success"
assert continued.worker_context_id == child_id
assert continued.result == "A1 continuation 3"
assert calls == {child_id: 3}
finally:
if child_id:
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_parallel_a1_spawns_a2_with_same_lifecycle(monkeypatch) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
parent_id = "ctx-parallel-a2-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
async def fake_monologue(agent):
return agent.agent_name
monkeypatch.setattr(Agent, "monologue", fake_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_ids = []
try:
a1_job = parallel_tools.ParallelJob(
id="callsubordin-a1",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "be A1", "reset": True},
kind="subordinate",
parent_agent=parent.agent0,
)
a1_result = await parallel_tools._run_subordinate_context_job(parent.id, a1_job)
a1 = AgentContext.get(a1_job.worker_context_id or "").agent0 # type: ignore[union-attr]
child_ids.append(a1.context.id)
a2_job = parallel_tools.ParallelJob(
id="callsubordin-a2",
parent_context_id=a1.context.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "be A2", "reset": True},
kind="subordinate",
parent_agent=a1,
)
a2_result = await parallel_tools._run_subordinate_context_job(
a1.context.id, a2_job
)
a2_context = AgentContext.get(a2_job.worker_context_id or "")
child_ids.append(a2_context.id) # type: ignore[union-attr]
assert a1_result == "A1"
assert a1.number == 1
assert a2_result == "A2"
assert a2_context.agent0.number == 2 # type: ignore[union-attr]
assert a2_context.get_output_data("parent_context_id") == a1.context.id # type: ignore[union-attr]
assert a2_context.get_output_data("parent_agent_number") == 1 # type: ignore[union-attr]
finally:
for child_id in reversed(child_ids):
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_parallel_subordinate_owns_nested_parallel_tasks(monkeypatch) -> None:
monkeypatch.setattr(parallel_tools, "DeferredTask", _FakeDeferredTask)
agent = _FakeAgent()
parent_task = _FakeDeferredTask()
agent.context.task = parent_task
agent.context.set_data(parallel_tools.PARALLEL_WORKER_KIND_KEY, "subordinate")
jobs = await parallel_tools.start_parallel_jobs(
agent, # type: ignore[arg-type]
[
parallel_tools.NormalizedToolCall(
index=0,
tool_name="call_subordinate",
tool_args={"message": "nested", "reset": True},
)
],
)
assert parent_task.children == [jobs[0].deferred_task]
parent_task.kill()
assert jobs[0].deferred_task.killed == 1 # type: ignore[union-attr]
@pytest.mark.asyncio
async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
class FakeDeferredTask:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
@ -10,10 +11,28 @@ from helpers.errors import RepairableException
class _FakeContext:
id = "ctx"
def __init__(self, id: str = "ctx") -> None:
self.id = id
self.name = None
self.data = {}
self.output_data = {}
self.created_at = datetime.now(timezone.utc)
self.agent0 = None
def get_data(self, key: str, recursive: bool = True):
return None
return self.data.get(key)
def set_data(self, key: str, value, recursive: bool = True):
self.data[key] = value
def get_output_data(self, key: str, recursive: bool = True):
return self.output_data.get(key)
def set_output_data(self, key: str, value, recursive: bool = True):
self.output_data[key] = value
def is_running(self) -> bool:
return False
class _FakeParentAgent:
@ -45,10 +64,17 @@ class _FakeSubAgent:
DATA_NAME_SUPERIOR = "_superior"
DATA_NAME_SUBORDINATE = "_subordinate"
def __init__(self, number: int, config: AgentConfig, context) -> None:
_counter = 0
def __init__(self, number: int, config: AgentConfig, context=None) -> None:
if context is None:
self.__class__._counter += 1
context = _FakeContext(f"child-{self.__class__._counter}")
self.number = number
self.agent_name = f"A{number}"
self.config = config
self.context = context
self.context.agent0 = self
self.data = {}
self.history = SimpleNamespace(new_topic=lambda: None)
self.messages = []
@ -56,6 +82,9 @@ class _FakeSubAgent:
def set_data(self, key: str, value):
self.data[key] = value
def get_data(self, key: str):
return self.data.get(key)
def hist_add_user_message(self, message):
self.messages.append(message)
@ -106,6 +135,12 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
profile=(override_settings or {}).get("agent_profile", "agent0"),
),
)
monkeypatch.setattr(
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
)
parent = _FakeParentAgent()
tool = call_subordinate.Delegation(
@ -118,13 +153,122 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
)
response = await tool.execute(message="work", profile="developer", reset=True)
child = parent.get_data(_FakeSubAgent.DATA_NAME_SUBORDINATE)
children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
child = next(iter(children.values()))
assert response.message == "delegated"
assert response.additional == {"context_id": child.context.id}
assert child.number == 1
assert child.config.profile == "developer"
assert child.messages[0].message == "work"
@pytest.mark.asyncio
async def test_call_subordinate_reset_false_reuses_numbered_child(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
monkeypatch.setattr(
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
)
parent = _FakeParentAgent()
tool = call_subordinate.Delegation(
parent, # type: ignore[arg-type]
"call_subordinate",
None,
{},
"",
None,
)
first = await tool.execute(message="first", reset=True)
second = await tool.execute(
message="continue",
context_id=first.additional["context_id"], # type: ignore[index]
reset=False,
)
children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
child = next(iter(children.values()))
assert len(children) == 1
assert child.number == 1
assert [message.message for message in child.messages] == ["first", "continue"]
assert second.additional == first.additional
def test_subordinate_tree_numbers_each_generation(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
parent = _FakeParentAgent()
child = call_subordinate.get_or_create_subordinate(
parent, # type: ignore[arg-type]
reset=True,
message="A1 work",
)
grandchild = call_subordinate.get_or_create_subordinate(
child, # type: ignore[arg-type]
reset=True,
message="A2 work",
)
assert child.number == 1
assert grandchild.number == 2
assert child.context.get_output_data("parent_context_id") == parent.context.id
assert grandchild.context.get_output_data("parent_context_id") == child.context.id
assert grandchild.get_data(Agent.DATA_NAME_SUPERIOR) is child
def test_subordinate_context_id_is_scoped_to_its_parent(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
owner = _FakeParentAgent()
other = _FakeParentAgent()
other.context = _FakeContext("other-parent")
child = call_subordinate.get_or_create_subordinate(
owner, # type: ignore[arg-type]
reset=True,
message="private branch",
)
with pytest.raises(RepairableException, match="was not found under A0"):
call_subordinate.get_or_create_subordinate(
other, # type: ignore[arg-type]
context_id=child.context.id,
reset=False,
)
@pytest.mark.asyncio
async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
@ -189,6 +333,56 @@ def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> Non
AgentContext.remove(context_id)
def test_persisted_numbered_child_is_reusable_after_reload(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
config_factory = lambda override_settings=None: AgentConfig(
mcp_servers="",
profile=(override_settings or {}).get("agent_profile", "agent0"),
)
monkeypatch.setattr(
persist_chat,
"initialize_agent",
config_factory,
)
monkeypatch.setattr(call_subordinate, "initialize_agent", config_factory)
parent_id = "ctx-persisted-agent-tree-parent"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
child = call_subordinate.get_or_create_subordinate(
parent.agent0,
reset=True,
message="persist me",
)
context_id = child.context.id
try:
assert len(persist_chat._serialize_context(parent)["agents"]) == 1
serialized = persist_chat._serialize_context(child.context)
AgentContext.remove(context_id)
parent.agent0.data.pop(call_subordinate.SUBORDINATES_DATA_KEY, None)
restored = persist_chat._deserialize_context(serialized)
resumed = call_subordinate.get_or_create_subordinate(
parent.agent0,
context_id=context_id,
reset=False,
)
assert restored.agent0.number == 1
assert restored.agent0.agent_name == "A1"
assert restored.get_output_data("parent_context_id") == parent.id
assert restored.get_output_data("parent_agent_number") == 0
assert resumed is restored.agent0
assert resumed.get_data(Agent.DATA_NAME_SUPERIOR) is parent.agent0
finally:
AgentContext.remove(context_id)
AgentContext.remove(parent_id)
@pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
@pytest.mark.asyncio
async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(

View file

@ -1,11 +1,20 @@
from agent import Agent, UserMessage
from helpers import projects, subagents
from agent import Agent, AgentContext, UserMessage
from helpers import message_queue, persist_chat, projects, subagents
from helpers.errors import RepairableException
from helpers.tool import Tool, Response
from initialize import initialize_agent
from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file
SUBORDINATES_DATA_KEY = "_subordinates"
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
CHILD_SUBORDINATE_SLOT_KEY = "subordinate_slot"
DEFAULT_SUBORDINATE_SLOT = "default"
def _subordinate_profile_labels(agent: Agent) -> dict[str, str]:
project = projects.get_context_project_name(agent.context) if agent.context else None
return {
@ -34,59 +43,196 @@ def _validate_subordinate_profile(agent: Agent, profile: str) -> str:
)
def _register_subordinate(parent: Agent, subordinate: Agent, slot: str) -> None:
subordinates = parent.get_data(SUBORDINATES_DATA_KEY)
if not isinstance(subordinates, dict):
subordinates = {}
parent.set_data(SUBORDINATES_DATA_KEY, subordinates)
subordinates[subordinate.context.id] = subordinate
subordinate.set_data(Agent.DATA_NAME_SUPERIOR, parent)
if slot == DEFAULT_SUBORDINATE_SLOT and subordinate.context is parent.context:
parent.set_data(Agent.DATA_NAME_SUBORDINATE, subordinate)
def _is_child_context(context: AgentContext, parent: Agent, slot: str | None = None) -> bool:
if context.get_output_data(CHILD_PARENT_CONTEXT_ID_KEY) != parent.context.id:
return False
if context.get_output_data(CHILD_PARENT_AGENT_NUMBER_KEY) != parent.number:
return False
if context.agent0.number != parent.number + 1:
return False
return slot is None or context.get_output_data(CHILD_SUBORDINATE_SLOT_KEY) == slot
def _is_live_context(context: AgentContext) -> bool:
return not isinstance(context, AgentContext) or AgentContext.get(context.id) is context
def _find_subordinate(parent: Agent, context_id: str, slot: str) -> Agent | None:
registered = parent.get_data(SUBORDINATES_DATA_KEY)
registered = registered if isinstance(registered, dict) else {}
if context_id:
subordinate = registered.get(context_id)
if (
subordinate
and _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent)
):
return subordinate
context = AgentContext.get(context_id)
if not context or not _is_child_context(context, parent):
raise RepairableException(
f"Subordinate context '{context_id}' was not found under {parent.agent_name}."
)
subordinate = context.agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
existing = parent.get_data(Agent.DATA_NAME_SUBORDINATE)
if slot == DEFAULT_SUBORDINATE_SLOT and existing is not None:
return existing
registered_matches = [
subordinate
for subordinate in registered.values()
if _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent, slot)
]
if registered_matches:
return max(
registered_matches,
key=lambda subordinate: subordinate.context.created_at,
)
matches = [
context
for context in AgentContext.all()
if _is_child_context(context, parent, slot)
]
if not matches:
return None
subordinate = max(matches, key=lambda context: context.created_at).agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
def get_or_create_subordinate(
parent: Agent,
*,
profile: str = "",
reset: bool | str = False,
context_id: str = "",
name: str = "",
message: str = "",
slot: str = DEFAULT_SUBORDINATE_SLOT,
) -> Agent:
requested_profile = _validate_subordinate_profile(parent, profile)
target_context_id = str(context_id or "").strip()
reset_requested = str(reset).lower().strip() == "true"
if target_context_id and reset_requested:
raise RepairableException(
"`context_id` continues an existing subordinate and requires reset=false. "
"Omit `context_id` to create a fresh subordinate."
)
subordinate = (
None
if reset_requested
else _find_subordinate(parent, target_context_id, slot)
)
if subordinate:
current_profile = str(getattr(subordinate.config, "profile", "") or "")
if requested_profile and current_profile != requested_profile:
raise RepairableException(
f"Subordinate already uses profile '{current_profile or 'default'}'. "
f"Set reset=true and omit `context_id` to switch to '{requested_profile}'."
)
if subordinate.context is not parent.context and subordinate.context.is_running():
raise RepairableException(
f"Subordinate context '{subordinate.context.id}' is still running. "
"Await or cancel its parallel job before continuing it."
)
return subordinate
override_settings = {"agent_profile": requested_profile} if requested_profile else None
subordinate = Agent(parent.number + 1, initialize_agent(override_settings=override_settings))
context = subordinate.context
context.name = str(name or "").strip() or _short_label(message) or subordinate.agent_name
context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent.context.id)
context.set_output_data(CHILD_PARENT_AGENT_NUMBER_KEY, parent.number)
context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "subordinate")
context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, context.name)
context.set_output_data(CHILD_SUBORDINATE_SLOT_KEY, slot)
project = projects.get_context_project_name(parent.context)
if project:
projects.activate_project(context.id, project, mark_dirty=False)
model_override = parent.context.get_data("chat_model_override")
if model_override:
context.set_data("chat_model_override", model_override)
_register_subordinate(parent, subordinate, slot)
return subordinate
async def run_subordinate(
parent: Agent,
subordinate: Agent,
message: str,
attachments: list[str] | None = None,
) -> str:
assignment = str(message or "").strip()
if not assignment:
raise RepairableException("call_subordinate requires a non-empty `message`.")
attachment_paths = [str(item) for item in attachments or []]
if subordinate.context is not parent.context:
message_queue.log_user_message(
subordinate.context,
assignment,
attachment_paths,
source=" (subordinate)",
)
subordinate.hist_add_user_message(
UserMessage(message=assignment, attachments=attachment_paths)
)
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
try:
result = await subordinate.monologue()
subordinate.history.new_topic()
return result
finally:
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
def _short_label(text: str, limit: int = 80) -> str:
return " ".join(str(text or "").split())[:limit].rstrip()
class Delegation(Tool):
async def execute(self, message="", reset="", **kwargs):
requested_profile = _validate_subordinate_profile(
self.agent, kwargs.get("profile", kwargs.get("agent_profile", ""))
async def execute(self, message="", reset="", context_id="", **kwargs):
attachments = kwargs.get("attachments")
attachments = attachments if isinstance(attachments, list) else []
subordinate = get_or_create_subordinate(
self.agent,
profile=kwargs.get("profile", kwargs.get("agent_profile", "")),
reset=reset,
context_id=context_id or kwargs.get("agent_id", ""),
name=kwargs.get("name", ""),
message=message,
)
existing_subordinate = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE)
reset_requested = str(reset).lower().strip() == "true"
if existing_subordinate and requested_profile and not reset_requested:
current_profile = str(
getattr(getattr(existing_subordinate, "config", None), "profile", "")
or ""
)
if current_profile != requested_profile:
raise RepairableException(
f"Subordinate already uses profile '{current_profile or 'default'}'. "
f"Set reset=true to switch to '{requested_profile}'."
)
# create subordinate agent using the data object on this agent and set superior agent to his data object
if (
existing_subordinate is None
or reset_requested
):
# set subordinate prompt profile if provided, otherwise use the default profile
override_settings = (
{"agent_profile": requested_profile} if requested_profile else None
)
config = initialize_agent(override_settings=override_settings)
# create agent
sub = Agent(self.agent.number + 1, config, self.agent.context)
# register superior/subordinate
sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent)
self.agent.set_data(Agent.DATA_NAME_SUBORDINATE, sub)
# add user message to subordinate agent
subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) # type: ignore
subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
# run subordinate monologue
result = await subordinate.monologue()
# seal the subordinate's current topic so messages move to `topics` for compression
subordinate.history.new_topic()
result = await run_subordinate(self.agent, subordinate, message, attachments)
# hint to use includes for long responses
additional = None
additional = {"context_id": subordinate.context.id}
if len(result) >= save_tool_call_file.LEN_MIN:
hint = self.agent.read_prompt("fw.hint.call_sub.md")
if hint:
additional = {"hint": hint}
additional["hint"] = hint
# result
return Response(message=result, break_loop=False, additional=additional)

View file

@ -12,11 +12,13 @@
- `call_subordinate.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
- Classes:
- `Delegation` (`Tool`)
- `async execute(self, message=..., reset=..., **kwargs)`
- `async execute(self, message=..., reset=..., context_id=..., **kwargs)`
- `get_log_object(self)`
- Top-level functions:
- `_subordinate_profile_labels(agent: Agent) -> dict[str, str]`
- `_validate_subordinate_profile(agent: Agent, profile: str) -> str`
- `get_or_create_subordinate(...) -> Agent`
- `run_subordinate(...) -> str`
## Runtime Contracts
@ -26,12 +28,20 @@
- `Delegation` defines `execute(...)`.
- Observed side-effect areas: filesystem writes, settings/state persistence.
- `profile`/`agent_profile` values are validated against available profile keys before use; unknown profiles raise `RepairableException` so the agent can retry with a real profile.
- Supplying a different profile for an existing subordinate without `reset=true` raises `RepairableException` instead of silently continuing the old subordinate.
- Direct and parallel calls use the same creation, continuation, message, history, and persistence functions in this module.
- Every fresh child is `Agent(parent.number + 1, ...)` in its own persisted child-chat context, so sibling A1 agents can each create their own A2 descendants without sharing streaming state.
- `reset=true` creates a fresh child. `reset=false` continues the caller's default child or the exact child named by `context_id`.
- Child context IDs are accepted only when their persisted parent context, parent agent number, and child depth match the caller.
- Supplying a different profile for an existing child without creating a fresh child raises `RepairableException` instead of silently changing its profile.
- Active parallel children cannot be continued concurrently; await or cancel their job first.
- Child contexts inherit the caller's project and selected chat-model override, are saved before execution and again on exit, and remain reusable after model/API failures.
- The direct tool result includes `context_id`; parallel job snapshots expose the same stable child ID separately from their per-invocation job ID.
- Existing same-context linear subordinates remain reusable for saved-chat compatibility, but new children use child contexts and a private per-parent registry.
- Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`.
## Key Concepts
- Important called helpers/classes observed in the source: `self.agent.get_data`, `projects.get_context_project_name`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `subordinate.hist_add_user_message`, `subordinate.history.new_topic`, `Response`, `self.agent.context.log.log`, `Agent`, `sub.set_data`, `self.agent.set_data`, `UserMessage`, `subordinate.monologue`, `self.agent.read_prompt`, `str.lower.strip`, `str.lower`.
- Important called helpers/classes observed in the source: `AgentContext.all`, `projects.get_context_project_name`, `projects.activate_project`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `message_queue.log_user_message`, `persist_chat.save_tmp_chat`, `UserMessage`, `subordinate.monologue`, and `subordinate.history.new_topic`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance
@ -46,6 +56,7 @@
- Related tests observed by source search:
- `tests/test_default_prompt_budget.py`
- `tests/test_subagent_profiles.py`
- `tests/test_parallel_tool.py`
## Child DOX Index

View file

@ -28,7 +28,8 @@
- `action="await"` waits for requested job IDs until completion or `timeout`; timeout returns running job handles without canceling them.
- `action="collect"` returns completed job results without waiting.
- `action="cancel"` requests cancellation for requested job IDs.
- Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; subordinate child chats started by `call_subordinate` can use normal child-chat tools, including `parallel`.
- Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; numbered subordinate child chats can use normal child-chat tools, including `parallel`, to create their next-level descendants.
- Wrapped `call_subordinate` uses the same lifecycle as a top-level call. `job_id` identifies one parallel invocation, while its returned `context_id` identifies the reusable child agent for later `reset=false` calls.
- The wrapper tool does not create its own visible process-step log; each wrapped child call owns the visible log row, and the wrapper result is recorded only in model history.
## Key Concepts