fix(agents): support parallel tool_calls (#944)#946
Conversation
The agent loop raised NotImplementedError on any response with len(tool_calls) > 1, which then hit the generic "malformed arguments" recovery prompt and bailed after three retries with zero tools fired. Tool-calling-trained models — including GAIA's own default Gemma-4-E4B-it-GGUF (#865) — routinely emit parallel tool_calls on multi-intent inputs, so this was a silent capability regression. Changes: - Parser (_parse_llm_response): normalises native tool_calls into a list of {id, name, tool_args} entries; preserves model-supplied ids and synthesises one when absent. Single-call legacy fields stay populated for N==1 so embedded-JSON consumers are unaffected. - Loop (process_query): adds a fan-out branch that drains all N calls sequentially in the same iteration before re-prompting the LLM. Errors no longer short-circuit the drain — all N results land in conversation history before STATE_ERROR_RECOVERY fires. - Assistant message: emits a proper OpenAI-shape assistant turn (content + tool_calls[]) instead of stuffing the raw sentinel envelope into content. Tool result messages now reference the originating call's real tool_call_id so spec-strict providers can correlate results to calls — also fixes parallel-same-name calls that previously had no way to disambiguate which result belonged to which call. - Provider (lemonade.py): surfaces assistant text emitted alongside tool_calls in the envelope's content field instead of dropping it, so models that explain themselves before calling (Gemma-4-E4B does this regularly) keep that context. Tests: 5 new integration tests covering the issue's acceptance criteria — two parallel calls execute and correlate; assistant turn carries OpenAI-shape tool_calls; three calls with one error drain fully; misleading recovery prompt is not triggered; single native calls now use real tool_call_id. Plus 4 new parser-layer tests (parallel returns list / three calls with mixed args / content alongside tool_calls / synthesised id when missing) and one updated to assert the new shape. 1800 unit tests pass.
SummarySolid fix for #944 — restores parallel Issues Found🟢 Minor1. Dead pre-loop assignment of
|
Should I close my pull request at #945 because I don't want to add any confusion to this |
Audit follow-up to the parallel tool_calls fix: the plan-response
branch (process_query line ~2751) was still appending the raw
{"__tool_calls__": ...} sentinel envelope as content, which would
leave a malformed assistant turn in history if a tool-calling-trained
planner emitted native tool_calls instead of (or alongside) the
expected plan JSON. The fan-out below would then add tool_call_ids
that don't link back to any structured assistant turn.
Factor the OpenAI-shape construction into _build_assistant_message
and use it from both the main response path and the plan response
path.
0f126a4 to
be24697
Compare
SummarySolid, well-motivated fix for a real default-model regression: Gemma-4-E4B (the GAIA default per #865) was emitting parallel The single most important thing the author should know: the new fan-out path duplicates ~100 lines of tool-execution logic from the legacy single-tool path (repeat detection, query dedup, Issues Found🟡 Important — Tool-execution logic duplicated between fan-out and legacy single-tool path (
|
Didn't realize you fixed it, you can complete yours since you already pushed a fix before me. |
yea I saw it the second you posted and attempted to tackle it I can close my PR if you want though I don't want to cause much confusion |
There was a problem hiding this comment.
@kovtcharov — approving. Verified locally on the PR branch:
pytest tests/unit/agents/test_parallel_tool_calls.py tests/unit/test_tool_call_priority.py -v— all new tests pass.- The two prior bot reviews land on every code-level concern I'd raise (dead
last_errorreset,_QUERY_TOOLSduplication, fan-out vs. legacy path drift, missing plan-priority guard,STATE_COMPLETIONflip skipped, over-permissive assertion intest_three_calls_one_errors_all_drain,_register_toolbypassing@tool). Worth working through that list before merge — thelast_errorreset and the plan-priority guard are easy in-PR cleanups; the broader fan-out/legacy duplication is a fair follow-up issue.
One small adjustment to the streaming concern from the first bot review (its item #5): the streaming branch in lemonade.py never sees native tool_calls because the provider force-disables streaming whenever tools are in play —
# lemonade.py:247
effective_stream = stream and not (tool_capable and tools)
So the agent's streaming-completion path can't currently regress parallel calls via this provider. A streaming smoke test is still worth having for defence in depth (a future provider could behave differently), but it's not a load-bearing risk today. Worth a one-line comment near the fan-out noting the assumption.
Beyond that — backward-compat strategy is well thought out (N==1 keeps legacy fields populated, N>1 forces callers onto the new path), _create_tool_message now propagates real tool_call_ids, and _build_assistant_message finally emits OpenAI-spec-shape assistant turns instead of the raw sentinel envelope. Solid work.
The existing PR-description guidance in CLAUDE.md was directionally
right ("tight and value-focused") but loose enough that a recent PR
(amd#946 / amd#944) still shipped with a "What changed" enumeration the diff
already showed and a "Summary" section that buried the user-observable
impact behind implementation details. Future agents reading the file
would do the same.
After this change the default shape is just two sections — "Why this
matters" (with required before/after framing) and "Test plan" — with a
"user-observable impact in <30s without reading the diff" litmus check,
and three new anti-patterns lifted directly from the patterns the prior
PR exhibited.
## Test plan
- [x] No code changed; doc-only edit
- [x] Re-read the new section against amd#946 to confirm the prior
description fails the new rules
The existing PR-description guidance in CLAUDE.md was directionally
right ("tight and value-focused") but loose enough that a recent PR
(amd#946 / amd#944) still shipped with a "What changed" enumeration the diff
already showed and a "Summary" section that buried the user-observable
impact behind implementation details. Future agents reading the file
would do the same.
After this change the default shape is just two sections — "Why this
matters" (with required before/after framing) and "Test plan" — with a
"user-observable impact in <30s without reading the diff" litmus check,
and three new anti-patterns lifted directly from the patterns the prior
PR exhibited.
## Test plan
- [x] No code changed; doc-only edit
- [x] Re-read the new section against amd#946 to confirm the prior
description fails the new rules
## Why this matters v0.18.0 ships agent memory v2 (hybrid-search second brain with LLM extraction and observability dashboard), ChatAgent split into three composable agents (Chat/FileIO/DocumentQA), parallel tool calls, and a Telegram adapter scaffold — plus fixes the RAG-on-PDF timeout with Gemma 4 that broke document Q&A since v0.17.6 and adds CI gates that enforce RAG quality baselines on every future PR. Full notes: `docs/releases/v0.18.0.mdx`. ## What's New - **Agent memory v2** ([amd#606](amd#606)) — Hybrid semantic + keyword search, LLM extraction, observability dashboard via SSE streaming ([amd#1032](amd#1032)). Per-user isolation enforced; extraction runs async so it doesn't add latency. - **ChatAgent split** ([amd#979](amd#979)) — `ChatAgent`, `FileIOAgent`, and `DocumentQAAgent` replace the monolithic class; each composable via `tools=`. Backward-compatible shim preserved. - **Parallel tool calls** ([amd#946](amd#946)) — Multiple `tool_calls` from a single LLM turn are executed concurrently, cutting round-trips for multi-tool workflows. - **Telegram adapter scaffold, Phase 0** ([amd#951](amd#951)) — `gaia telegram start|stop|status`, per-user session isolation, `[telegram]` extras. Phase 1 (message handling + allowed-users gate) tracked in [amd#889](amd#889). - **Connectors: per-MCP toggle + single-writer enforcement** ([amd#1018](amd#1018), [amd#998](amd#998)) — Disable individual MCP servers without removing them; concurrent writes serialised with actionable errors on contention. - **File navigation, web browsing, and write security** ([amd#495](amd#495)) — `FileSearchToolsMixin`, web browsing tool, and scratchpad mixin in `KNOWN_TOOLS`; write tools check `allowed_paths` before dispatch. - **Email UI and policy alerts** ([amd#995](amd#995), [amd#1039](amd#1039), [amd#952](amd#952)) — Pre-scan triage card, in-chat Connect, policy alert cards, and durable receipts for confirmation-gated actions. ## Bug Fixes - **RAG-on-PDF timeouts on Gemma 4** ([amd#1034](amd#1034), closes [amd#1030](amd#1030)) — Prompt-size budget check added at composition time; CI gates enforce it on every PR ([amd#1040](amd#1040)). - **Envelope-level parse failure crashed SD recovery** ([amd#1047](amd#1047), closes [amd#1023](amd#1023)) — Falls through to a clean recovery path with step-1 context preserved. - **Windows-path tool args corrupted** ([amd#1027](amd#1027)) — Backslash normalisation now happens after argument parsing. - **Blender `send_command` hung** ([amd#1026](amd#1026), closes [amd#1022](amd#1022)) — Read timeout applied to persistent-connection servers. - **`gaia chat init` in post-install banner** ([amd#1029](amd#1029), closes [amd#1024](amd#1024)) — Replaced with the correct `gaia init`. - **Keyring treated as required** ([amd#1028](amd#1028)) — Import guarded; optional on systems without `keyring`. - **electron-builder URLs stale** ([amd#953](amd#953)) — Three doc/installer files updated to current download paths. ## Tooling & Docs - **RAG eval CI gates** ([amd#1040](amd#1040), closes [amd#1033](amd#1033)) — RAG quality baselines + prompt-size budget enforced on every PR. - **Fork-PR authors now receive Claude review** ([amd#932](amd#932)) — `allowed_non_write_users: "*"` with prompt-injection mitigations documented. - **Eval runs mandated before merging** ([amd#1036](amd#1036)) — `CLAUDE.md` requires `gaia eval agent` for LLM-affecting changes. - **GAIA website** ([amd#369](amd#369)) — [amd-gaia.ai](https://amd-gaia.ai) live. - **Custom agent guide reorganised** ([amd#997](amd#997)), Lemonade PPA docs ([amd#801](amd#801)), broken Lemonade CLI URL fixed ([amd#996](amd#996)), WhatsApp adapter evaluation spec ([amd#950](amd#950)). ## Release checklist - [x] `util/validate_release_notes.py docs/releases/v0.18.0.mdx --tag v0.18.0` passes - [x] `src/gaia/version.py` → `0.18.0` - [x] `src/gaia/apps/webui/package.json` → `0.18.0` - [x] Navbar label in `docs/docs.json` → `v0.18.0 · Lemonade 10.2.0` - [x] All 28 commits in range (v0.17.6..HEAD) are represented in the notes - [ ] Review from @kovtcharov-amd addressed
Closes #944.
Why this matters
GAIA's default tool-calling model (Gemma-4-E4B-it-GGUF, set in #865) emits multiple
tool_callsper turn on multi-intent inputs. The agent loop rejected those, retried with a misleading "malformed arguments" prompt, and bailed with zero tools fired. Result: the default model couldn't handle multi-intent utterances at all.After this PR, multi-intent utterances work — N parallel calls drain in one loop iteration, errors don't short-circuit, and assistant text emitted alongside tool_calls (some Gemma variants do this) is preserved instead of dropped.
Test plan
pytest tests/unit/agents/test_parallel_tool_calls.py— 5 new integration tests covering the issue's acceptance criteriapytest tests/unit/test_tool_call_priority.py— 4 new parser tests + 1 updated for the new shapepytest tests/unit/— full suite still green (2 pre-existing context-overflow flakes are unrelated, fail on main too)python util/lint.py --all— clean