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