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.