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.

Alessandro committed Aug 19, 2026 at 09:30 UTC a304c7665fff47cc0956e79ed8898eb070db425f
9 files changed +713 -121
helpers/parallel_tools.py
+27 -65
@@ -23,6 +23,7 @@ PARALLEL_WORKER_JOB_KEY = "_parallel_job_id"
23 PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind"
24
25 CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
26 +CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
27 CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
28 CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
29 CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id"
@@ -53,6 +54,7 @@ class ParallelJob:
54 tool_name: str
55 tool_args: dict[str, Any]
56 kind: JobKind
57 + parent_agent: "Agent | None" = field(default=None, repr=False)
58 state: JobState = "pending"
59 created_at: float = field(default_factory=time.time)
60 started_at: float | None = None
@@ -208,6 +210,7 @@ async def start_parallel_jobs(
210 tool_name=call.tool_name,
211 tool_args=call.tool_args,
212 kind=kind,
213 + parent_agent=agent,
214 )
215 job_store[job.id] = job
216 jobs.append(job)
@@ -218,6 +221,8 @@ async def start_parallel_jobs(
221 job.started_at = time.time()
222 task = DeferredTask(thread_name=THREAD_BACKGROUND)
223 job.deferred_task = task
224 + if _parallel_worker_kind(agent) == "subordinate" and context.task:
225 + context.task.add_child_task(task)
226 task.start_task(_run_parallel_job, context.id, job.id)
227 except Exception as exc:
228 _finish_job(job, "error", error=str(exc))
@@ -410,34 +415,39 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
415
416
417 async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
413 - from agent import AgentContext, AgentContextType, UserMessage
414 - from helpers import message_queue, persist_chat
418 + from agent import AgentContext
419 from helpers.tool_policy import ensure_tool_allowed
416 - from tools.call_subordinate import _validate_subordinate_profile
420 + from tools.call_subordinate import get_or_create_subordinate, run_subordinate
421
422 parent_context = AgentContext.get(parent_context_id)
423 if not parent_context:
424 raise ValueError("Parent context not found.")
421 - ensure_tool_allowed(parent_context.agent0, "call_subordinate")
425 + parent_agent = job.parent_agent or parent_context.agent0
426 + ensure_tool_allowed(parent_agent, "call_subordinate")
427
428 args = job.tool_args
429 message = str(args.get("message") or "").strip()
430 if not message:
431 raise ValueError("call_subordinate requires `tool_args.message`.")
432
428 - profile = _validate_subordinate_profile(
429 - parent_context.agent0,
430 - str(args.get("profile") or args.get("agent_profile") or ""),
433 + context_id = str(args.get("context_id") or args.get("agent_id") or "").strip()
434 + reset = args.get("reset", False)
435 + slot = (
436 + job.id
437 + if coerce_bool(reset, False) and not context_id
438 + else "default"
439 )
440 attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
433 - attachments = [str(item) for item in attachments]
434 -
435 - child_name = _subordinate_context_name(job)
436 - worker_context = AgentContext(
437 - config=_clone_config(parent_context.config, profile=profile),
438 - name=child_name,
439 - type=AgentContextType.USER,
441 + subordinate = get_or_create_subordinate(
442 + parent_agent,
443 + profile=str(args.get("profile") or args.get("agent_profile") or ""),
444 + reset=reset,
445 + context_id=context_id,
446 + name=str(args.get("name") or ""),
447 + message=message,
448 + slot=slot,
449 )
450 + worker_context = subordinate.context
451 job.worker_context_id = worker_context.id
452 if job.deferred_task:
453 worker_context.task = job.deferred_task
@@ -445,30 +455,9 @@ async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob)
455 worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id)
456 worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
457 worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind)
448 - worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id)
449 - worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
450 - worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name)
458 worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id)
459 worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name)
453 - _copy_project(parent_context, worker_context)
454 -
455 - system_prompt = _subordinate_worker_system_prompt(profile)
456 - message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)")
457 - worker_context.agent0.hist_add_user_message(
458 - UserMessage(
459 - message=message,
460 - attachments=attachments,
461 - system_message=[system_prompt],
462 - )
463 - )
464 - persist_chat.save_tmp_chat(worker_context)
465 -
466 - try:
467 - result = await worker_context.agent0.monologue()
468 - worker_context.agent0.history.new_topic()
469 - return result
470 - finally:
471 - persist_chat.save_tmp_chat(worker_context)
460 + return await run_subordinate(parent_agent, subordinate, message, attachments)
461
462
463 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]:
700 return data
701
702
714 -def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig":
703 +def _clone_config(config: "AgentConfig") -> "AgentConfig":
704 try:
716 - cloned = replace(
705 + return replace(
706 config,
707 knowledge_subdirs=list(config.knowledge_subdirs),
708 additional=dict(config.additional),
709 )
721 - if profile:
722 - cloned.profile = profile
723 - return cloned
710 except Exception:
711 return config
712
@@ -734,27 +720,3 @@ def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext"
720 projects.activate_project(worker_context.id, project_name, mark_dirty=False)
721 except Exception:
722 pass
737 -
738 -
739 -def _subordinate_worker_system_prompt(profile: str) -> str:
740 - lines = [
741 - "You are running as an isolated parallel worker for a parent Agent Zero chat.",
742 - "Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.",
743 - ]
744 - if profile:
745 - lines.append(f"Act with the `{profile}` profile's expertise and priorities.")
746 - return "\n".join(lines)
747 -
748 -
749 -def _subordinate_context_name(job: ParallelJob) -> str:
750 - name = str(job.tool_args.get("name") or "").strip()
751 - if name:
752 - return name
753 - message = str(job.tool_args.get("message") or "").strip()
754 - label = _short_label(message)
755 - return label or f"Parallel subordinate {job.index + 1}"
756 -
757 -
758 -def _short_label(text: str, limit: int = 80) -> str:
759 - compact = " ".join(text.split())
760 - return compact[:limit].rstrip()
helpers/parallel_tools.py.dox.md
+5 -5
@@ -26,11 +26,11 @@
26 - 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.
27 - `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
28 - 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.
29 -- `call_subordinate` jobs first enforce the parent profile's delegation policy
30 - and validate the requested profile through the sequential delegation owner,
31 - then run in isolated child chat contexts tagged with parent-chat metadata;
32 - they must not be added to the scheduler task list and may use normal child-chat
33 - tools, including `parallel`.
29 +- `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.
30 +- 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.
31 +- Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0.
32 +- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
33 +- Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
34 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
35 - Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
36 - 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.
prompts/agent.system.tool.call_sub.md
+4 -2
@@ -1,9 +1,11 @@
1 ### call_subordinate
2 delegate research or complex subtasks to a specialized agent.
3 -args: `message`, optional `profile`, `reset`
3 +args: `message`, optional `profile`, `reset`, `context_id`
4 - `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile
5 -- `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue
5 +- `reset`: use json boolean `true` to create a fresh child; use `false` to continue the default child or the supplied `context_id`
6 +- `context_id`: stable child ID returned by an earlier direct or parallel call; use it with `reset: false` to continue that exact child
7 - `message`: define role, goal, and the concrete task
8 +each caller creates its next agent level: A0 creates A1 children, A1 creates A2 children, and so on
9 after the subordinate returns, answer from its result directly when it satisfies the user request
10 do not repeat the same solving work or call extra tools after a sufficient subordinate result
11 example:
prompts/agent.system.tool.parallel.md
+1 -1
@@ -10,7 +10,7 @@ Rules:
10 - never nest `parallel`
11 - Never include `document_query` in `tool_calls`; it is too heavy for parallel workers, so call it sequentially.
12 - Call `response` only as a top-level tool so it ends the message loop; never wrap it inside `parallel.tool_calls`.
13 -- `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task
13 +- `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`
14 - use `wait: false` only when you will collect results later with `job_ids`
15 - if extras list running or ready parallel jobs, collect them before final synthesis
16 - `timeout` only limits how long this call waits; running jobs continue and can be awaited again by `job_ids`
tests/test_parallel_tool.py
+277 -1
@@ -48,6 +48,7 @@ class _FakeContext:
48 self.id = "ctx"
49 self.data = {}
50 self.log = _FakeLog()
51 + self.task = None
52
53 def get_data(self, key: str, recursive: bool = True):
54 return self.data.get(key)
@@ -60,14 +61,29 @@ class _FakeAgent:
61 def __init__(self) -> None:
62 self.context = _FakeContext()
63 self.agent_name = "A0"
64 + self.number = 0
65
66
67 class _FakeDeferredTask:
66 - def __init__(self, *, ready: bool = False, alive: bool = True, result=None) -> None:
68 + def __init__(
69 + self,
70 + *,
71 + ready: bool = False,
72 + alive: bool = True,
73 + result=None,
74 + thread_name=None,
75 + ) -> None:
76 self.ready = ready
77 self.alive = alive
78 self._result = result
79 self.killed = 0
80 + self.thread_name = thread_name
81 + self.started = None
82 + self.children = []
83 +
84 + def start_task(self, func, *args):
85 + self.started = (func, args)
86 + return self
87
88 def is_ready(self):
89 return self.ready
@@ -81,6 +97,12 @@ class _FakeDeferredTask:
97 def kill(self):
98 self.killed += 1
99 self.alive = False
100 + for child in self.children:
101 + child.kill()
102 + self.children = []
103 +
104 + def add_child_task(self, task, terminate_thread=False):
105 + self.children.append(task)
106
107
108 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:
160 assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
161
162
163 +def test_subordinate_prompts_share_reusable_tree_contract() -> None:
164 + call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
165 + encoding="utf-8"
166 + )
167 + parallel_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.parallel.md").read_text(
168 + encoding="utf-8"
169 + )
170 +
171 + assert "A0 creates A1 children, A1 creates A2 children" in call_prompt
172 + assert "stable child ID" in call_prompt
173 + assert "same child lifecycle here as it does top-level" in parallel_prompt
174 + assert "each job's `context_id`" in parallel_prompt
175 +
176 +
177 def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
178 with pytest.raises(ValueError, match="cannot be nested"):
179 parallel_tools.normalize_parallel_tool_calls(
@@ -475,6 +511,246 @@ async def test_parallel_subordinate_reuses_profile_validation(monkeypatch) -> No
511 await parallel_tools._run_subordinate_context_job("ctx", job)
512
513
514 +@pytest.mark.asyncio
515 +async def test_parallel_subordinates_are_distinct_reusable_a1_children(monkeypatch) -> None:
516 + from agent import Agent, AgentConfig, AgentContext
517 + from helpers import message_queue, persist_chat, tool_policy
518 +
519 + parent_id = "ctx-parallel-a1-tree"
520 + AgentContext.remove(parent_id)
521 + parent = AgentContext(
522 + AgentConfig(mcp_servers="", profile="agent0"),
523 + id=parent_id,
524 + set_current=False,
525 + )
526 +
527 + async def fake_monologue(agent):
528 + return agent.agent_name
529 +
530 + monkeypatch.setattr(Agent, "monologue", fake_monologue)
531 + monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
532 + monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
533 + monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
534 +
535 + child_ids = []
536 + try:
537 + jobs = await parallel_tools.start_parallel_jobs(
538 + parent.agent0,
539 + [
540 + parallel_tools.NormalizedToolCall(
541 + index=0,
542 + tool_name="call_subordinate",
543 + tool_args={"message": "left branch", "reset": True},
544 + ),
545 + parallel_tools.NormalizedToolCall(
546 + index=1,
547 + tool_name="call_subordinate",
548 + tool_args={"message": "right branch", "reset": True},
549 + ),
550 + ],
551 + )
552 + results = await parallel_tools.await_parallel_jobs(
553 + parent.agent0,
554 + [job.id for job in jobs],
555 + timeout=10,
556 + )
557 + child_ids = [result["context_id"] for result in results]
558 +
559 + assert [result["state"] for result in results] == ["success", "success"]
560 + assert [result["result"] for result in results] == ["A1", "A1"]
561 + assert len(set(child_ids)) == 2
562 + assert set(parent.agent0.get_data("_subordinates")) == set(child_ids)
563 + for child_id in child_ids:
564 + child = AgentContext.get(child_id)
565 + assert child is not None
566 + assert child.agent0.number == 1
567 + assert child.get_output_data("parent_context_id") == parent.id
568 + assert child.get_output_data("parent_agent_number") == 0
569 + assert child.get_output_data("parent_context_kind") == "subordinate"
570 + finally:
571 + for child_id in child_ids:
572 + AgentContext.remove(child_id)
573 + AgentContext.remove(parent_id)
574 +
575 +
576 +@pytest.mark.asyncio
577 +async def test_failed_parallel_subordinate_continues_directly_or_in_parallel(
578 + monkeypatch,
579 +) -> None:
580 + from agent import Agent, AgentConfig, AgentContext
581 + from helpers import message_queue, persist_chat, tool_policy
582 + from tools.call_subordinate import Delegation
583 +
584 + parent_id = "ctx-parallel-resume-tree"
585 + AgentContext.remove(parent_id)
586 + parent = AgentContext(
587 + AgentConfig(mcp_servers="", profile="agent0"),
588 + id=parent_id,
589 + set_current=False,
590 + )
591 + calls = {}
592 +
593 + async def flaky_monologue(agent):
594 + count = calls.get(agent.context.id, 0) + 1
595 + calls[agent.context.id] = count
596 + if count == 1:
597 + raise RuntimeError("simulated API failure")
598 + return f"{agent.agent_name} continuation {count}"
599 +
600 + monkeypatch.setattr(Agent, "monologue", flaky_monologue)
601 + monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
602 + monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
603 + monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
604 +
605 + child_id = ""
606 + try:
607 + failed = parallel_tools.ParallelJob(
608 + id="callsubordin-failed",
609 + parent_context_id=parent.id,
610 + index=0,
611 + tool_name="call_subordinate",
612 + tool_args={"message": "remember ALPHA", "reset": True},
613 + kind="subordinate",
614 + parent_agent=parent.agent0,
615 + )
616 + parallel_tools._jobs_for_context(parent)[failed.id] = failed
617 + await parallel_tools._run_parallel_job(parent.id, failed.id)
618 + child_id = failed.worker_context_id or ""
619 +
620 + assert failed.state == "error"
621 + assert failed.error == "simulated API failure"
622 + assert child_id
623 + assert AgentContext.get(child_id).agent0.number == 1 # type: ignore[union-attr]
624 +
625 + direct = Delegation(
626 + parent.agent0,
627 + "call_subordinate",
628 + None,
629 + {},
630 + "",
631 + None,
632 + )
633 + direct_result = await direct.execute(
634 + message="continue after the API failure",
635 + context_id=child_id,
636 + reset=False,
637 + )
638 + assert direct_result.message == "A1 continuation 2"
639 + assert direct_result.additional == {"context_id": child_id}
640 +
641 + continued = parallel_tools.ParallelJob(
642 + id="callsubordin-continued",
643 + parent_context_id=parent.id,
644 + index=0,
645 + tool_name="call_subordinate",
646 + tool_args={
647 + "message": "continue once more",
648 + "context_id": child_id,
649 + "reset": False,
650 + },
651 + kind="subordinate",
652 + parent_agent=parent.agent0,
653 + )
654 + parallel_tools._jobs_for_context(parent)[continued.id] = continued
655 + await parallel_tools._run_parallel_job(parent.id, continued.id)
656 +
657 + assert continued.state == "success"
658 + assert continued.worker_context_id == child_id
659 + assert continued.result == "A1 continuation 3"
660 + assert calls == {child_id: 3}
661 + finally:
662 + if child_id:
663 + AgentContext.remove(child_id)
664 + AgentContext.remove(parent_id)
665 +
666 +
667 +@pytest.mark.asyncio
668 +async def test_parallel_a1_spawns_a2_with_same_lifecycle(monkeypatch) -> None:
669 + from agent import Agent, AgentConfig, AgentContext
670 + from helpers import message_queue, persist_chat, tool_policy
671 +
672 + parent_id = "ctx-parallel-a2-tree"
673 + AgentContext.remove(parent_id)
674 + parent = AgentContext(
675 + AgentConfig(mcp_servers="", profile="agent0"),
676 + id=parent_id,
677 + set_current=False,
678 + )
679 +
680 + async def fake_monologue(agent):
681 + return agent.agent_name
682 +
683 + monkeypatch.setattr(Agent, "monologue", fake_monologue)
684 + monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
685 + monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
686 + monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
687 +
688 + child_ids = []
689 + try:
690 + a1_job = parallel_tools.ParallelJob(
691 + id="callsubordin-a1",
692 + parent_context_id=parent.id,
693 + index=0,
694 + tool_name="call_subordinate",
695 + tool_args={"message": "be A1", "reset": True},
696 + kind="subordinate",
697 + parent_agent=parent.agent0,
698 + )
699 + a1_result = await parallel_tools._run_subordinate_context_job(parent.id, a1_job)
700 + a1 = AgentContext.get(a1_job.worker_context_id or "").agent0 # type: ignore[union-attr]
701 + child_ids.append(a1.context.id)
702 +
703 + a2_job = parallel_tools.ParallelJob(
704 + id="callsubordin-a2",
705 + parent_context_id=a1.context.id,
706 + index=0,
707 + tool_name="call_subordinate",
708 + tool_args={"message": "be A2", "reset": True},
709 + kind="subordinate",
710 + parent_agent=a1,
711 + )
712 + a2_result = await parallel_tools._run_subordinate_context_job(
713 + a1.context.id, a2_job
714 + )
715 + a2_context = AgentContext.get(a2_job.worker_context_id or "")
716 + child_ids.append(a2_context.id) # type: ignore[union-attr]
717 +
718 + assert a1_result == "A1"
719 + assert a1.number == 1
720 + assert a2_result == "A2"
721 + assert a2_context.agent0.number == 2 # type: ignore[union-attr]
722 + assert a2_context.get_output_data("parent_context_id") == a1.context.id # type: ignore[union-attr]
723 + assert a2_context.get_output_data("parent_agent_number") == 1 # type: ignore[union-attr]
724 + finally:
725 + for child_id in reversed(child_ids):
726 + AgentContext.remove(child_id)
727 + AgentContext.remove(parent_id)
728 +
729 +
730 +@pytest.mark.asyncio
731 +async def test_parallel_subordinate_owns_nested_parallel_tasks(monkeypatch) -> None:
732 + monkeypatch.setattr(parallel_tools, "DeferredTask", _FakeDeferredTask)
733 + agent = _FakeAgent()
734 + parent_task = _FakeDeferredTask()
735 + agent.context.task = parent_task
736 + agent.context.set_data(parallel_tools.PARALLEL_WORKER_KIND_KEY, "subordinate")
737 +
738 + jobs = await parallel_tools.start_parallel_jobs(
739 + agent, # type: ignore[arg-type]
740 + [
741 + parallel_tools.NormalizedToolCall(
742 + index=0,
743 + tool_name="call_subordinate",
744 + tool_args={"message": "nested", "reset": True},
745 + )
746 + ],
747 + )
748 +
749 + assert parent_task.children == [jobs[0].deferred_task]
750 + parent_task.kill()
751 + assert jobs[0].deferred_task.killed == 1 # type: ignore[union-attr]
752 +
753 +
754 @pytest.mark.asyncio
755 async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
756 class FakeDeferredTask:
tests/test_subagent_profiles.py
+198 -4
@@ -1,5 +1,6 @@
1 from __future__ import annotations
2
3 +from datetime import datetime, timezone
4 from types import SimpleNamespace
5
6 import pytest
@@ -10,10 +11,28 @@ from helpers.errors import RepairableException
11
12
13 class _FakeContext:
13 - id = "ctx"
14 + def __init__(self, id: str = "ctx") -> None:
15 + self.id = id
16 + self.name = None
17 + self.data = {}
18 + self.output_data = {}
19 + self.created_at = datetime.now(timezone.utc)
20 + self.agent0 = None
21
22 def get_data(self, key: str, recursive: bool = True):
16 - return None
23 + return self.data.get(key)
24 +
25 + def set_data(self, key: str, value, recursive: bool = True):
26 + self.data[key] = value
27 +
28 + def get_output_data(self, key: str, recursive: bool = True):
29 + return self.output_data.get(key)
30 +
31 + def set_output_data(self, key: str, value, recursive: bool = True):
32 + self.output_data[key] = value
33 +
34 + def is_running(self) -> bool:
35 + return False
36
37
38 class _FakeParentAgent:
@@ -45,10 +64,17 @@ class _FakeSubAgent:
64 DATA_NAME_SUPERIOR = "_superior"
65 DATA_NAME_SUBORDINATE = "_subordinate"
66
48 - def __init__(self, number: int, config: AgentConfig, context) -> None:
67 + _counter = 0
68 +
69 + def __init__(self, number: int, config: AgentConfig, context=None) -> None:
70 + if context is None:
71 + self.__class__._counter += 1
72 + context = _FakeContext(f"child-{self.__class__._counter}")
73 self.number = number
74 + self.agent_name = f"A{number}"
75 self.config = config
76 self.context = context
77 + self.context.agent0 = self
78 self.data = {}
79 self.history = SimpleNamespace(new_topic=lambda: None)
80 self.messages = []
@@ -56,6 +82,9 @@ class _FakeSubAgent:
82 def set_data(self, key: str, value):
83 self.data[key] = value
84
85 + def get_data(self, key: str):
86 + return self.data.get(key)
87 +
88 def hist_add_user_message(self, message):
89 self.messages.append(message)
90
@@ -106,6 +135,12 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
135 profile=(override_settings or {}).get("agent_profile", "agent0"),
136 ),
137 )
138 + monkeypatch.setattr(
139 + call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
140 + )
141 + monkeypatch.setattr(
142 + call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
143 + )
144
145 parent = _FakeParentAgent()
146 tool = call_subordinate.Delegation(
@@ -118,13 +153,122 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
153 )
154
155 response = await tool.execute(message="work", profile="developer", reset=True)
121 - child = parent.get_data(_FakeSubAgent.DATA_NAME_SUBORDINATE)
156 + children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
157 + child = next(iter(children.values()))
158
159 assert response.message == "delegated"
160 + assert response.additional == {"context_id": child.context.id}
161 + assert child.number == 1
162 assert child.config.profile == "developer"
163 assert child.messages[0].message == "work"
164
165
166 +@pytest.mark.asyncio
167 +async def test_call_subordinate_reset_false_reuses_numbered_child(monkeypatch) -> None:
168 + import tools.call_subordinate as call_subordinate
169 +
170 + monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
171 + monkeypatch.setattr(
172 + call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
173 + )
174 + monkeypatch.setattr(
175 + call_subordinate,
176 + "initialize_agent",
177 + lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
178 + )
179 + monkeypatch.setattr(
180 + call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
181 + )
182 + monkeypatch.setattr(
183 + call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
184 + )
185 +
186 + parent = _FakeParentAgent()
187 + tool = call_subordinate.Delegation(
188 + parent, # type: ignore[arg-type]
189 + "call_subordinate",
190 + None,
191 + {},
192 + "",
193 + None,
194 + )
195 + first = await tool.execute(message="first", reset=True)
196 + second = await tool.execute(
197 + message="continue",
198 + context_id=first.additional["context_id"], # type: ignore[index]
199 + reset=False,
200 + )
201 +
202 + children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
203 + child = next(iter(children.values()))
204 + assert len(children) == 1
205 + assert child.number == 1
206 + assert [message.message for message in child.messages] == ["first", "continue"]
207 + assert second.additional == first.additional
208 +
209 +
210 +def test_subordinate_tree_numbers_each_generation(monkeypatch) -> None:
211 + import tools.call_subordinate as call_subordinate
212 +
213 + monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
214 + monkeypatch.setattr(
215 + call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
216 + )
217 + monkeypatch.setattr(
218 + call_subordinate,
219 + "initialize_agent",
220 + lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
221 + )
222 +
223 + parent = _FakeParentAgent()
224 + child = call_subordinate.get_or_create_subordinate(
225 + parent, # type: ignore[arg-type]
226 + reset=True,
227 + message="A1 work",
228 + )
229 + grandchild = call_subordinate.get_or_create_subordinate(
230 + child, # type: ignore[arg-type]
231 + reset=True,
232 + message="A2 work",
233 + )
234 +
235 + assert child.number == 1
236 + assert grandchild.number == 2
237 + assert child.context.get_output_data("parent_context_id") == parent.context.id
238 + assert grandchild.context.get_output_data("parent_context_id") == child.context.id
239 + assert grandchild.get_data(Agent.DATA_NAME_SUPERIOR) is child
240 +
241 +
242 +def test_subordinate_context_id_is_scoped_to_its_parent(monkeypatch) -> None:
243 + import tools.call_subordinate as call_subordinate
244 +
245 + monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
246 + monkeypatch.setattr(
247 + call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
248 + )
249 + monkeypatch.setattr(
250 + call_subordinate,
251 + "initialize_agent",
252 + lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
253 + )
254 +
255 + owner = _FakeParentAgent()
256 + other = _FakeParentAgent()
257 + other.context = _FakeContext("other-parent")
258 + child = call_subordinate.get_or_create_subordinate(
259 + owner, # type: ignore[arg-type]
260 + reset=True,
261 + message="private branch",
262 + )
263 +
264 + with pytest.raises(RepairableException, match="was not found under A0"):
265 + call_subordinate.get_or_create_subordinate(
266 + other, # type: ignore[arg-type]
267 + context_id=child.context.id,
268 + reset=False,
269 + )
270 +
271 +
272 @pytest.mark.asyncio
273 async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
274 import tools.call_subordinate as call_subordinate
@@ -189,6 +333,56 @@ def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> Non
333 AgentContext.remove(context_id)
334
335
336 +def test_persisted_numbered_child_is_reusable_after_reload(monkeypatch) -> None:
337 + import tools.call_subordinate as call_subordinate
338 +
339 + config_factory = lambda override_settings=None: AgentConfig(
340 + mcp_servers="",
341 + profile=(override_settings or {}).get("agent_profile", "agent0"),
342 + )
343 + monkeypatch.setattr(
344 + persist_chat,
345 + "initialize_agent",
346 + config_factory,
347 + )
348 + monkeypatch.setattr(call_subordinate, "initialize_agent", config_factory)
349 +
350 + parent_id = "ctx-persisted-agent-tree-parent"
351 + AgentContext.remove(parent_id)
352 + parent = AgentContext(
353 + AgentConfig(mcp_servers="", profile="agent0"),
354 + id=parent_id,
355 + set_current=False,
356 + )
357 + child = call_subordinate.get_or_create_subordinate(
358 + parent.agent0,
359 + reset=True,
360 + message="persist me",
361 + )
362 + context_id = child.context.id
363 + try:
364 + assert len(persist_chat._serialize_context(parent)["agents"]) == 1
365 + serialized = persist_chat._serialize_context(child.context)
366 + AgentContext.remove(context_id)
367 + parent.agent0.data.pop(call_subordinate.SUBORDINATES_DATA_KEY, None)
368 + restored = persist_chat._deserialize_context(serialized)
369 + resumed = call_subordinate.get_or_create_subordinate(
370 + parent.agent0,
371 + context_id=context_id,
372 + reset=False,
373 + )
374 +
375 + assert restored.agent0.number == 1
376 + assert restored.agent0.agent_name == "A1"
377 + assert restored.get_output_data("parent_context_id") == parent.id
378 + assert restored.get_output_data("parent_agent_number") == 0
379 + assert resumed is restored.agent0
380 + assert resumed.get_data(Agent.DATA_NAME_SUPERIOR) is parent.agent0
381 + finally:
382 + AgentContext.remove(context_id)
383 + AgentContext.remove(parent_id)
384 +
385 +
386 @pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
387 @pytest.mark.asyncio
388 async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(
tools/call_subordinate.py
+185 -39
@@ -1,11 +1,20 @@
1 -from agent import Agent, UserMessage
2 -from helpers import projects, subagents
1 +from agent import Agent, AgentContext, UserMessage
2 +from helpers import message_queue, persist_chat, projects, subagents
3 from helpers.errors import RepairableException
4 from helpers.tool import Tool, Response
5 from initialize import initialize_agent
6 from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file
7
8
9 +SUBORDINATES_DATA_KEY = "_subordinates"
10 +CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
11 +CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
12 +CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
13 +CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
14 +CHILD_SUBORDINATE_SLOT_KEY = "subordinate_slot"
15 +DEFAULT_SUBORDINATE_SLOT = "default"
16 +
17 +
18 def _subordinate_profile_labels(agent: Agent) -> dict[str, str]:
19 project = projects.get_context_project_name(agent.context) if agent.context else None
20 return {
@@ -34,59 +43,196 @@ def _validate_subordinate_profile(agent: Agent, profile: str) -> str:
43 )
44
45
37 -class Delegation(Tool):
46 +def _register_subordinate(parent: Agent, subordinate: Agent, slot: str) -> None:
47 + subordinates = parent.get_data(SUBORDINATES_DATA_KEY)
48 + if not isinstance(subordinates, dict):
49 + subordinates = {}
50 + parent.set_data(SUBORDINATES_DATA_KEY, subordinates)
51 + subordinates[subordinate.context.id] = subordinate
52 + subordinate.set_data(Agent.DATA_NAME_SUPERIOR, parent)
53 + if slot == DEFAULT_SUBORDINATE_SLOT and subordinate.context is parent.context:
54 + parent.set_data(Agent.DATA_NAME_SUBORDINATE, subordinate)
55
39 - async def execute(self, message="", reset="", **kwargs):
40 - requested_profile = _validate_subordinate_profile(
41 - self.agent, kwargs.get("profile", kwargs.get("agent_profile", ""))
42 - )
43 - existing_subordinate = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE)
44 - reset_requested = str(reset).lower().strip() == "true"
56
46 - if existing_subordinate and requested_profile and not reset_requested:
47 - current_profile = str(
48 - getattr(getattr(existing_subordinate, "config", None), "profile", "")
49 - or ""
50 - )
51 - if current_profile != requested_profile:
52 - raise RepairableException(
53 - f"Subordinate already uses profile '{current_profile or 'default'}'. "
54 - f"Set reset=true to switch to '{requested_profile}'."
55 - )
57 +def _is_child_context(context: AgentContext, parent: Agent, slot: str | None = None) -> bool:
58 + if context.get_output_data(CHILD_PARENT_CONTEXT_ID_KEY) != parent.context.id:
59 + return False
60 + if context.get_output_data(CHILD_PARENT_AGENT_NUMBER_KEY) != parent.number:
61 + return False
62 + if context.agent0.number != parent.number + 1:
63 + return False
64 + return slot is None or context.get_output_data(CHILD_SUBORDINATE_SLOT_KEY) == slot
65 +
66
57 - # create subordinate agent using the data object on this agent and set superior agent to his data object
67 +def _is_live_context(context: AgentContext) -> bool:
68 + return not isinstance(context, AgentContext) or AgentContext.get(context.id) is context
69 +
70 +
71 +def _find_subordinate(parent: Agent, context_id: str, slot: str) -> Agent | None:
72 + registered = parent.get_data(SUBORDINATES_DATA_KEY)
73 + registered = registered if isinstance(registered, dict) else {}
74 + if context_id:
75 + subordinate = registered.get(context_id)
76 if (
59 - existing_subordinate is None
60 - or reset_requested
77 + subordinate
78 + and _is_live_context(subordinate.context)
79 + and _is_child_context(subordinate.context, parent)
80 ):
62 - # set subordinate prompt profile if provided, otherwise use the default profile
63 - override_settings = (
64 - {"agent_profile": requested_profile} if requested_profile else None
81 + return subordinate
82 + context = AgentContext.get(context_id)
83 + if not context or not _is_child_context(context, parent):
84 + raise RepairableException(
85 + f"Subordinate context '{context_id}' was not found under {parent.agent_name}."
86 )
66 - config = initialize_agent(override_settings=override_settings)
87 + subordinate = context.agent0
88 + _register_subordinate(parent, subordinate, slot)
89 + return subordinate
90 +
91 + existing = parent.get_data(Agent.DATA_NAME_SUBORDINATE)
92 + if slot == DEFAULT_SUBORDINATE_SLOT and existing is not None:
93 + return existing
94 +
95 + registered_matches = [
96 + subordinate
97 + for subordinate in registered.values()
98 + if _is_live_context(subordinate.context)
99 + and _is_child_context(subordinate.context, parent, slot)
100 + ]
101 + if registered_matches:
102 + return max(
103 + registered_matches,
104 + key=lambda subordinate: subordinate.context.created_at,
105 + )
106
68 - # create agent
69 - sub = Agent(self.agent.number + 1, config, self.agent.context)
70 - # register superior/subordinate
71 - sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent)
72 - self.agent.set_data(Agent.DATA_NAME_SUBORDINATE, sub)
107 + matches = [
108 + context
109 + for context in AgentContext.all()
110 + if _is_child_context(context, parent, slot)
111 + ]
112 + if not matches:
113 + return None
114 + subordinate = max(matches, key=lambda context: context.created_at).agent0
115 + _register_subordinate(parent, subordinate, slot)
116 + return subordinate
117 +
118 +
119 +def get_or_create_subordinate(
120 + parent: Agent,
121 + *,
122 + profile: str = "",
123 + reset: bool | str = False,
124 + context_id: str = "",
125 + name: str = "",
126 + message: str = "",
127 + slot: str = DEFAULT_SUBORDINATE_SLOT,
128 +) -> Agent:
129 + requested_profile = _validate_subordinate_profile(parent, profile)
130 + target_context_id = str(context_id or "").strip()
131 + reset_requested = str(reset).lower().strip() == "true"
132 + if target_context_id and reset_requested:
133 + raise RepairableException(
134 + "`context_id` continues an existing subordinate and requires reset=false. "
135 + "Omit `context_id` to create a fresh subordinate."
136 + )
137
74 - # add user message to subordinate agent
75 - subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) # type: ignore
76 - subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
138 + subordinate = (
139 + None
140 + if reset_requested
141 + else _find_subordinate(parent, target_context_id, slot)
142 + )
143 + if subordinate:
144 + current_profile = str(getattr(subordinate.config, "profile", "") or "")
145 + if requested_profile and current_profile != requested_profile:
146 + raise RepairableException(
147 + f"Subordinate already uses profile '{current_profile or 'default'}'. "
148 + f"Set reset=true and omit `context_id` to switch to '{requested_profile}'."
149 + )
150 + if subordinate.context is not parent.context and subordinate.context.is_running():
151 + raise RepairableException(
152 + f"Subordinate context '{subordinate.context.id}' is still running. "
153 + "Await or cancel its parallel job before continuing it."
154 + )
155 + return subordinate
156 +
157 + override_settings = {"agent_profile": requested_profile} if requested_profile else None
158 + subordinate = Agent(parent.number + 1, initialize_agent(override_settings=override_settings))
159 + context = subordinate.context
160 + context.name = str(name or "").strip() or _short_label(message) or subordinate.agent_name
161 + context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent.context.id)
162 + context.set_output_data(CHILD_PARENT_AGENT_NUMBER_KEY, parent.number)
163 + context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "subordinate")
164 + context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, context.name)
165 + context.set_output_data(CHILD_SUBORDINATE_SLOT_KEY, slot)
166 +
167 + project = projects.get_context_project_name(parent.context)
168 + if project:
169 + projects.activate_project(context.id, project, mark_dirty=False)
170 + model_override = parent.context.get_data("chat_model_override")
171 + if model_override:
172 + context.set_data("chat_model_override", model_override)
173 +
174 + _register_subordinate(parent, subordinate, slot)
175 + return subordinate
176 +
177 +
178 +async def run_subordinate(
179 + parent: Agent,
180 + subordinate: Agent,
181 + message: str,
182 + attachments: list[str] | None = None,
183 +) -> str:
184 + assignment = str(message or "").strip()
185 + if not assignment:
186 + raise RepairableException("call_subordinate requires a non-empty `message`.")
187 +
188 + attachment_paths = [str(item) for item in attachments or []]
189 + if subordinate.context is not parent.context:
190 + message_queue.log_user_message(
191 + subordinate.context,
192 + assignment,
193 + attachment_paths,
194 + source=" (subordinate)",
195 + )
196 + subordinate.hist_add_user_message(
197 + UserMessage(message=assignment, attachments=attachment_paths)
198 + )
199 + if subordinate.context is not parent.context:
200 + persist_chat.save_tmp_chat(subordinate.context)
201
78 - # run subordinate monologue
202 + try:
203 result = await subordinate.monologue()
80 -
81 - # seal the subordinate's current topic so messages move to `topics` for compression
204 subordinate.history.new_topic()
205 + return result
206 + finally:
207 + if subordinate.context is not parent.context:
208 + persist_chat.save_tmp_chat(subordinate.context)
209 +
210 +
211 +def _short_label(text: str, limit: int = 80) -> str:
212 + return " ".join(str(text or "").split())[:limit].rstrip()
213 +
214 +
215 +class Delegation(Tool):
216 +
217 + async def execute(self, message="", reset="", context_id="", **kwargs):
218 + attachments = kwargs.get("attachments")
219 + attachments = attachments if isinstance(attachments, list) else []
220 + subordinate = get_or_create_subordinate(
221 + self.agent,
222 + profile=kwargs.get("profile", kwargs.get("agent_profile", "")),
223 + reset=reset,
224 + context_id=context_id or kwargs.get("agent_id", ""),
225 + name=kwargs.get("name", ""),
226 + message=message,
227 + )
228 + result = await run_subordinate(self.agent, subordinate, message, attachments)
229
230 # hint to use includes for long responses
85 - additional = None
231 + additional = {"context_id": subordinate.context.id}
232 if len(result) >= save_tool_call_file.LEN_MIN:
233 hint = self.agent.read_prompt("fw.hint.call_sub.md")
234 if hint:
89 - additional = {"hint": hint}
235 + additional["hint"] = hint
236
237 # result
238 return Response(message=result, break_loop=False, additional=additional)
tools/call_subordinate.py.dox.md
+14 -3
@@ -12,11 +12,13 @@
12 - `call_subordinate.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 - Classes:
14 - `Delegation` (`Tool`)
15 - - `async execute(self, message=..., reset=..., **kwargs)`
15 + - `async execute(self, message=..., reset=..., context_id=..., **kwargs)`
16 - `get_log_object(self)`
17 - Top-level functions:
18 - `_subordinate_profile_labels(agent: Agent) -> dict[str, str]`
19 - `_validate_subordinate_profile(agent: Agent, profile: str) -> str`
20 +- `get_or_create_subordinate(...) -> Agent`
21 +- `run_subordinate(...) -> str`
22
23 ## Runtime Contracts
24
@@ -26,12 +28,20 @@
28 - `Delegation` defines `execute(...)`.
29 - Observed side-effect areas: filesystem writes, settings/state persistence.
30 - `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.
29 -- Supplying a different profile for an existing subordinate without `reset=true` raises `RepairableException` instead of silently continuing the old subordinate.
31 +- Direct and parallel calls use the same creation, continuation, message, history, and persistence functions in this module.
32 +- 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.
33 +- `reset=true` creates a fresh child. `reset=false` continues the caller's default child or the exact child named by `context_id`.
34 +- Child context IDs are accepted only when their persisted parent context, parent agent number, and child depth match the caller.
35 +- Supplying a different profile for an existing child without creating a fresh child raises `RepairableException` instead of silently changing its profile.
36 +- Active parallel children cannot be continued concurrently; await or cancel their job first.
37 +- 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.
38 +- The direct tool result includes `context_id`; parallel job snapshots expose the same stable child ID separately from their per-invocation job ID.
39 +- Existing same-context linear subordinates remain reusable for saved-chat compatibility, but new children use child contexts and a private per-parent registry.
40 - Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`.
41
42 ## Key Concepts
43
34 -- 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`.
44 +- 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`.
45 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
46
47 ## Work Guidance
@@ -46,6 +56,7 @@
56 - Related tests observed by source search:
57 - `tests/test_default_prompt_budget.py`
58 - `tests/test_subagent_profiles.py`
59 + - `tests/test_parallel_tool.py`
60
61 ## Child DOX Index
62
tools/parallel.py.dox.md
+2 -1
@@ -28,7 +28,8 @@
28 - `action="await"` waits for requested job IDs until completion or `timeout`; timeout returns running job handles without canceling them.
29 - `action="collect"` returns completed job results without waiting.
30 - `action="cancel"` requests cancellation for requested job IDs.
31 -- 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`.
31 +- 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.
32 +- 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.
33 - 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.
34
35 ## Key Concepts