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.
This commit is contained in:
Alessandro 2026-06-26 13:47:35 +02:00
parent 5c914bc49e
commit b11818bfff
4 changed files with 44 additions and 1 deletions

View file

@ -78,6 +78,13 @@ def extract_tool_calls(args: dict[str, Any]) -> Any:
def normalize_parallel_tool_calls(raw_calls: Any) -> list[NormalizedToolCall]:
if isinstance(raw_calls, str):
try:
raw_calls = json.loads(raw_calls)
except json.JSONDecodeError as exc:
raise ValueError(
"`tool_calls` must be an array of normal tool-call objects."
) from exc
if not isinstance(raw_calls, list):
raise ValueError("`tool_calls` must be an array of normal tool-call objects.")
if not raw_calls:

View file

@ -24,6 +24,7 @@
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Wrapped tool-call items must use the same shape as normal tool calls: a tool name plus arguments.
- 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.
- `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
- Normalization rejects `document_query` inside `parallel` because document parsing and Q&A fan out into heavier worker/model paths that must run sequentially.
- `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`.
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.

View file

@ -103,6 +103,41 @@ def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> N
assert calls[1].tool_args == {"method": "list_tasks", "action": "list_tasks"}
def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
calls = parallel_tools.normalize_parallel_tool_calls(
json.dumps(
[
{
"tool_name": "call_subordinate",
"tool_args": {
"profile": "researcher",
"reset": True,
"message": "Research nuclear fusion news in French.",
},
"headline": "Researching nuclear fusion news in French",
},
{
"tool_name": "call_subordinate",
"tool_args": {
"profile": "researcher",
"reset": True,
"message": "Research nuclear fusion news in Italian.",
},
"headline": "Researching nuclear fusion news in Italian",
},
]
)
)
assert [call.tool_name for call in calls] == [
"call_subordinate",
"call_subordinate",
]
assert calls[0].tool_args["profile"] == "researcher"
assert calls[0].tool_args["reset"] is True
assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
with pytest.raises(ValueError, match="cannot be nested"):
parallel_tools.normalize_parallel_tool_calls(

View file

@ -32,7 +32,7 @@
## Key Concepts
- `tool_calls`, `calls`, and `items` are accepted aliases for the wrapped call list.
- `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.
- Wrapped call items can use the same `tool_name`/`tool_args` shape as top-level agent replies; extra planning fields are ignored by normalization.
- `job_ids` can be supplied as a string or list when awaiting, collecting, or canceling existing jobs.
- The response is compact JSON intended for the model to read and continue with.