Clarify parallel tool-call batching

- document that parallel accepts full reply-shaped tool call objects and ignores planning-only fields - steer prompt guidance toward one mixed batch for ready independent calls instead of splitting by tool type - add normalization and prompt regression coverage plus matching DOX notes

Alessandro committed Jun 12, 2026 at 18:32 UTC 6eeddf2f046153f520526da46da4a3c584c74649
4 files changed +37 -4
helpers/parallel_tools.py.dox.md
+1
@@ -23,6 +23,7 @@
23
24 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
25 - Wrapped tool-call items must use the same shape as normal tool calls: a tool name plus arguments.
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 - `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list.
28 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
29 - 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.parallel.md
+6 -4
@@ -1,10 +1,12 @@
1 ### parallel
2 run independent tool calls concurrently, or await/cancel background parallel jobs.
3
4 -Use only for independent work. Each `tool_calls` item is a normal tool request object: `{ "tool_name": "...", "tool_args": { ... } }`.
4 +Use only for independent work. Each `tool_calls` item is a normal tool request object using the same `tool_name` and `tool_args` shape as a top-level reply: `{ "tool_name": "...", "tool_args": { ... } }`.
5 +Only `tool_name` and `tool_args` are used; if an item is copied from a full reply object, planning fields like `thoughts` or `headline` are ignored.
6 +Batch all independent calls that are ready now into one `tool_calls` list, even when they use different tools. Do not split by tool type.
7
8 Rules:
7 -- do not use for one simple call, dependent steps, ordered steps, or shared mutable state
9 +- do not use for one simple call, dependent steps, ordered steps, shared mutable state, or state/tool-availability changes that must happen in the parent context
10 - never nest `parallel`
11 - `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task
12 - use `wait: false` only when you will collect results later with `job_ids`
@@ -18,8 +20,8 @@ Start and wait:
20 "tool_name": "parallel",
21 "tool_args": {
22 "tool_calls": [
21 - {"tool_name": "call_subordinate", "tool_args": {"message": "Research option A.", "reset": true}},
22 - {"tool_name": "call_subordinate", "tool_args": {"message": "Research option B.", "reset": true}}
23 + {"tool_name": "call_subordinate", "tool_args": {"message": "Review option A and return key risks.", "reset": true}},
24 + {"tool_name": "search_engine", "tool_args": {"query": "official API changelog release notes"}}
25 ],
26 "wait": true
27 }
tests/test_tool_request_normalization.py
+28
@@ -10,6 +10,7 @@ if str(PROJECT_ROOT) not in sys.path:
10 sys.path.insert(0, str(PROJECT_ROOT))
11
12 from helpers.extract_tools import normalize_tool_request
13 +from helpers import parallel_tools
14
15
16 def test_normalize_tool_request_accepts_canonical_keys() -> None:
@@ -65,3 +66,30 @@ def test_normalize_tool_request_preserves_explicit_action_over_method() -> None:
66 def test_normalize_tool_request_rejects_missing_args() -> None:
67 with pytest.raises(ValueError, match="tool_args"):
68 normalize_tool_request({"tool_name": "response"})
69 +
70 +
71 +def test_normalize_parallel_tool_calls_accepts_full_agent_reply_shape() -> None:
72 + calls = parallel_tools.normalize_parallel_tool_calls(
73 + [
74 + {
75 + "thoughts": ["This is independent and ready to run."],
76 + "headline": "Search Python release notes",
77 + "tool_name": "search_engine",
78 + "tool_args": {"query": "latest Python version changelog"},
79 + }
80 + ]
81 + )
82 +
83 + assert calls[0].tool_name == "search_engine"
84 + assert calls[0].tool_args == {"query": "latest Python version changelog"}
85 +
86 +
87 +def test_parallel_prompt_encourages_mixed_independent_batches() -> None:
88 + prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.parallel.md").read_text(
89 + encoding="utf-8"
90 + )
91 +
92 + assert "same `tool_name` and `tool_args` shape as a top-level reply" in prompt
93 + assert "planning fields like `thoughts` or `headline` are ignored" in prompt
94 + assert "even when they use different tools" in prompt
95 + assert "Do not split by tool type" in prompt
tools/parallel.py.dox.md
+2
@@ -21,6 +21,7 @@
21 - Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
22 - Wrapped items use the same schema as normal tool calls: a tool name plus arguments.
23 - The tool is intended for independent calls only; dependent operations remain sequential.
24 +- Independent calls should share one batch even when they use different tools; split only for dependencies, ordering, shared mutable state, or parent-context state/tool-availability changes.
25 - `action="start"` starts calls and optionally waits according to `wait`.
26 - `action="await"` waits for requested job IDs.
27 - `action="collect"` returns completed job results without waiting.
@@ -31,6 +32,7 @@
32 ## Key Concepts
33
34 - `tool_calls`, `calls`, and `items` are accepted aliases for the wrapped call list.
35 +- Wrapped call items can use the same `tool_name`/`tool_args` shape as top-level agent replies; extra planning fields are ignored by normalization.
36 - `job_ids` can be supplied as a string or list when awaiting, collecting, or canceling existing jobs.
37 - The response is compact JSON intended for the model to read and continue with.
38 - Visible child rows are emitted by `helpers/parallel_tools.py` before each background job starts.