Accept JSON-string parallel tool calls

Restore parallel normalization tolerance for provider/model outputs that stringify the tool_calls array inside tool_args. The wrapper still expects normal tool-call objects after decoding, so nested parallel and document_query guards continue to run through the same validation path.

Alessandro committed Jun 26, 2026 at 13:47 UTC b11818bfffa6519bc16140d3e6db079a515e87d0
4 files changed +44 -1
helpers/parallel_tools.py
+7
@@ -78,6 +78,13 @@ def extract_tool_calls(args: dict[str, Any]) -> Any:
78
79
80 def normalize_parallel_tool_calls(raw_calls: Any) -> list[NormalizedToolCall]:
81 + if isinstance(raw_calls, str):
82 + try:
83 + raw_calls = json.loads(raw_calls)
84 + except json.JSONDecodeError as exc:
85 + raise ValueError(
86 + "`tool_calls` must be an array of normal tool-call objects."
87 + ) from exc
88 if not isinstance(raw_calls, list):
89 raise ValueError("`tool_calls` must be an array of normal tool-call objects.")
90 if not raw_calls:
helpers/parallel_tools.py.dox.md
+1
@@ -24,6 +24,7 @@
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 +- `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` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially.
29 - `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list and may use normal child-chat tools, including `parallel`.
30 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
tests/test_parallel_tool.py
+35
@@ -103,6 +103,41 @@ def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> N
103 assert calls[1].tool_args == {"method": "list_tasks", "action": "list_tasks"}
104
105
106 +def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
107 + calls = parallel_tools.normalize_parallel_tool_calls(
108 + json.dumps(
109 + [
110 + {
111 + "tool_name": "call_subordinate",
112 + "tool_args": {
113 + "profile": "researcher",
114 + "reset": True,
115 + "message": "Research nuclear fusion news in French.",
116 + },
117 + "headline": "Researching nuclear fusion news in French",
118 + },
119 + {
120 + "tool_name": "call_subordinate",
121 + "tool_args": {
122 + "profile": "researcher",
123 + "reset": True,
124 + "message": "Research nuclear fusion news in Italian.",
125 + },
126 + "headline": "Researching nuclear fusion news in Italian",
127 + },
128 + ]
129 + )
130 + )
131 +
132 + assert [call.tool_name for call in calls] == [
133 + "call_subordinate",
134 + "call_subordinate",
135 + ]
136 + assert calls[0].tool_args["profile"] == "researcher"
137 + assert calls[0].tool_args["reset"] is True
138 + assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
139 +
140 +
141 def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
142 with pytest.raises(ValueError, match="cannot be nested"):
143 parallel_tools.normalize_parallel_tool_calls(
tools/parallel.py.dox.md
+1 -1
@@ -32,7 +32,7 @@
32
33 ## Key Concepts
34
35 -- `tool_calls`, `calls`, and `items` are accepted aliases for the wrapped call list.
35 +- `tool_calls`, `calls`, and `items` are accepted aliases for the wrapped call list; a valid JSON string encoding of the list is tolerated for provider/model recovery.
36 - Wrapped call items can use the same `tool_name`/`tool_args` shape as top-level agent replies; extra planning fields are ignored by normalization.
37 - `job_ids` can be supplied as a string or list when awaiting, collecting, or canceling existing jobs.
38 - The response is compact JSON intended for the model to read and continue with.