fix(agent): parse Qwen/Hermes <tool_call> bare-JSON tool calls in text mode#5199
fix(agent): parse Qwen/Hermes <tool_call> bare-JSON tool calls in text mode#5199harshit-ojha0324 wants to merge 2 commits into
Conversation
…afts Per the search-before-staging rule, each affected draft now states complements/conflicts vs merged and in-flight upstream work (see upstream-recon-2026-07-07.md): tool-parsing (odysseus-dev#5033/odysseus-dev#5275/odysseus-dev#5199), provider hosts + native-tool toggle (odysseus-dev#4729/odysseus-dev#5206), injection header + guard benchmark (odysseus-dev#4991), GGUF include filter (odysseus-dev#5136/odysseus-dev#5137), ntfy/note refactor (odysseus-dev#5208/odysseus-dev#5236), skills + teacher volatility (odysseus-dev#5261/odysseus-dev#5215/odysseus-dev#4962). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alteixeira20
left a comment
There was a problem hiding this comment.
The focused Qwen/Hermes wrapped-JSON parser looks good, but the branch currently conflicts with dev in src/tool_parsing.py, so the guarded audit cannot validate the actual merge result.
I transplanted the PR's focused changes onto the current dev head locally and verified:
git diff --checkpasses;- the changed parser and test file compile;
- the PR tests and adjacent parser regression suites pass;
- 130 tests passed in total;
- multiple wrapped JSON calls remain ordered;
- mixed XML and JSON wrappers parse correctly;
- bare JSON outside
<tool_call>remains inert; - malformed and unknown calls are ignored;
- the malformed-opener stress case remains linear and completed in approximately 0.03 seconds.
Please rebase the branch onto the latest dev and resolve the src/tool_parsing.py conflict while preserving the per-wrapper fallback logic. Once the PR is mergeable again, I will rerun the guarded audit against the new pinned head.
Non-blocking note: a complete wrapper followed by an unclosed final wrapper currently recovers only the complete call. The standalone unclosed-wrapper case works as described. This combination is outside the reported reproduction, but it may be worth documenting or adding coverage for later.
…t mode
parse_tool_blocks matched the <tool_call> wrapper but only looked for
<invoke>/<parameter> XML inside it. Qwen and Hermes/NousResearch models
are trained to emit the OpenAI function-call object as JSON directly in
the wrapper:
<tool_call>
{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}
</tool_call>
In text mode (a manually added Ollama endpoint, native_tools=False) the
block matched no inner format, so it fell through as inert text — the
agent talked but never acted. Qwen is one of the most common local
families on Ollama, so this hit many users.
Add a JSON-body fallback that runs only after the <invoke>/<direct> XML
parses come up empty, reusing function_call_to_tool_block (the same
converter used for native calls). It handles arguments as an object or
a JSON-encoded string, tool-name aliases (shell -> bash), and multiple
<tool_call> blocks in one turn (tracked per-wrapper, not globally).
Bare JSON outside a <tool_call> wrapper is deliberately not parsed, so
example JSON in prose can't execute. Stripping already removed the
wrapper, so no strip change is needed.
Scope: the canonical <tool_call> JSON format only. The [bash]...[/bash]
square-bracket drift the same report mentions has no wrapper to
disambiguate it from prose; native tool calling via the per-endpoint
supports_tools flag is the right fix for those cases.
Fixes odysseus-dev#5187
71c26f7 to
30a5cc8
Compare
|
Rebased onto the latest Re-verified on the new head: focused + adjacent parser regression suites pass, ordered multi-call and mixed XML+JSON wrappers parse correctly, bare JSON outside a wrapper stays inert, and the 20k-opener stress case is still linear (~0.02s). On the non-blocking note — good catch, you're right: a complete wrapper followed by an unclosed final wrapper recovers only the complete call, because the closed-wrapper pass finds a block and the unclosed-wrapper fallback is gated on |
…ng args
Two parser-safety issues in the <tool_call> bare-JSON path:
P1 — the wrapper parser scanned the body as direct XML before the JSON
fallback, so XML-like text inside a valid JSON argument selected a
different tool: a wrapped write_file whose content was
'<bash>echo unsafe</bash>' was parsed as a Bash call and the write
dropped. Classify a JSON-looking wrapper body ({...}/[...]) as JSON
first and fail closed — never fall through to the XML scan — so embedded
tags stay data for the intended tool.
P2 — {"command": 1} built a ToolBlock with integer content; the agent
loop then called .strip() on it and aborted the turn. Reject a converted
block whose content isn't a string (fail closed).
Adds regressions: XML-like string inside a JSON arg stays data, an
unconvertible JSON body fails closed instead of XML-scanning, and numeric
command/code values are rejected.
Refs odysseus-dev#5187
|
Both good catches, confirmed and fixed in P1: the wrapper loop now classifies a JSON-looking body ( P2: On the safety boundary: New head is the P1/P2 fixes on top of the rebased branch; the focused and adjacent parser suites are green and the 20k-opener stress stays linear (~0.01s). |
Summary
parse_tool_blocksmatched the<tool_call>wrapper but only looked for<invoke>/<parameter>XML inside it. Qwen and Hermes/NousResearch models are trained to emit the OpenAI function-call object as JSON directly inside the wrapper:In text mode — a manually added Ollama endpoint,
native_tools=False— the block matched no inner format and fell through as inert text, so the agent talked but never acted. Since Qwen is one of the most common local families on Ollama, this hit a lot of "the agent narrates but nothing runs" reports. This adds a JSON-body fallback that runs only after the existing<invoke>/<direct>XML parses come up empty, reusingfunction_call_to_tool_block— the same converter used for native structured tool calls. It handlesargumentsas an object or a JSON-encoded string (a Hermes variant), tool-name aliases (shell→bash), and multiple<tool_call>blocks in one turn (tracked per-wrapper, not globally, since Qwen commonly emits several). Bare JSON outside a<tool_call>wrapper is deliberately not parsed, so example JSON in prose can't execute — the wrapper is the disambiguating signal. Stripping already removed the<tool_call>wrapper regardless of its contents, so no strip change is needed.Scope: the canonical
<tool_call>JSON format from the issue title. The[bash]…[/bash]square-bracket drift the same report mentions is intentionally out of scope — it has no wrapper to disambiguate it from prose, so parsing it globally would risk executing example text; native tool calling via the per-endpointsupports_toolsflag (filed separately as #5192) is the right fix for those finetune-drift cases.Target branch
dev, notmain.Linked Issue
Fixes #5187
Type of Change
Checklist
devdocker compose uporuvicorn app:app) and verified the change works end-to-end. Type-checks and unit tests are not enough.How to Test
Fed the issue's exact
<tool_call>payload through the public parser (from src.agent_tools import parse_tool_blocks, strip_tool_blocks) — the pathsrc/agent_loop.pycalls on every agent round:Verified through
parse_tool_blocks(resp, skip_fenced=…)with bothskip_fenced=FalseandTrue(Pattern 3 runs regardless of the fenced-skip mode the agent loop chooses per model), and confirmed the issue's two-call turn (mkdir + python script) yields both blocks in order.Behaviours covered by the new
tests/test_qwen_tool_call_parsing.py(9 cases):<tool_call>JSON parses and strips clean<tool_call>JSON blocks in one turn all parse, in orderargumentsas a JSON-encoded string (Hermes variant)shell→bash)</tool_call>closer (streamed truncation) still parses<invoke>/<parameter>XML still parsesChecks run:
ReDoS check on the new path:
parse_tool_blocks('<tool_call>{"name":"bash",' * 20000)returns in ~0.02 s — the JSON fallback runs inside the existing forward-only_iter_delimitedscan, so it doesn't reintroduce the O(n²) lazy-rescan the surrounding code guards against.Visual / UI changes — REQUIRED if you touched anything that renders
Not applicable — backend parser change (
src/tool_parsing.py+ tests); nothing that renders was touched.