fix(agent): recover gemma4 tool_call.arguments corrupted by leaked chat-template markers#19887
fix(agent): recover gemma4 tool_call.arguments corrupted by leaked chat-template markers#19887Naroh091 wants to merge 1 commit into
Conversation
…at-template markers When using Gemma 4 served via vLLM (Nous Builders, NaN Builders, others), the harmony parser intermittently structures the tool call cleanly (so ``tool_calls`` is non-empty and ``finish_reason='tool_calls'`` from the upstream) but lets the model's chat-template quote markers (``<|"|>`` in its full and partial forms) escape into ``tool_call.arguments`` as literal characters. Hermes' downstream ``json.loads(arguments)`` then fails. The retry sees the same deterministic corruption, fails again, and the run is surfaced to the user as ``Response truncated (finish_reason='length') — model hit max output tokens`` even though no token cap was hit and the upstream returned successfully. This is on top of the existing content-side recovery path (the structured channel was assumed safe). Add a conservative sanitizer that strips the leaked markers from structured ``tool_call.arguments``. To avoid masking unrelated bugs the sanitizer only rewrites ``arguments`` when both: * the ORIGINAL is unparseable as JSON, and * the CLEANED variant IS parseable as JSON. Already-valid args are never touched, even if they cosmetically contain ``<|`` substrings (legitimate user content can do this). Validated against captured failing payloads from a gemma4 + NaN Builders session (``kanban_complete`` with nested ``metadata.findings``): 12 already-valid tool_calls untouched, 2 broken tool_calls recovered, 0 collateral damage.
|
For reviewer context: this is a defensive client-side fix on top of an upstream vLLM root cause that's separately tracked. vllm-project/vllm#39069 — "gemma4_utils._parse_tool_arguments truncates string values containing internal quotes" — identifies the exact mechanism. The offline parser at Two related upstream bugs round out the picture:
Even after vllm#39069 lands, the Hermes-side sanitizer remains useful as defense-in-depth: it protects clients running older vLLM, custom-served Gemma 4 endpoints, and any provider that derives its tool-call parser from the same code. The conservative double-guard ("only rewrite when original is unparseable AND cleaned IS parseable") means it's a no-op once upstream is fixed — the original arguments will already parse, and the sanitizer will skip them. |
Symptom: local Qwen3.5 via llama-server with --jinja occasionally generates "\nuser\n[<user_name>] ..." past the assistant stop boundary. Hermes formats user messages as "[<user_name>] ..." (gateway/run.py:5692), so the model learned that pattern as the user-turn delimiter; when sampling drifts past the assistant stop, it continues into a plausible user turn. The gateway delivers that verbatim to Telegram — the bot appears to literally echo "[ilyass] nudge" back at the user. Belt + suspenders: - run_agent.py: inject stop sequences ["\nuser\n[", "\n\nuser\n"] into chat_completions api_kwargs after _build_api_kwargs(). Cuts generation at the leak boundary, saving tokens too. Disable via HERMES_DISABLE_USER_PREFIX_STOP=1. - gateway/run.py: post-process final_response with a regex that detects and strips "\nuser\n[<name>] ..." patterns. Safety net for any leak that escapes the stop sequence (e.g., on streaming providers where stop detection differs). Logs a warning when it fires so we can monitor. Related upstream (all open, none merged): NousResearch#6272 (strip [CONTEXT COMPACTION] echoes), NousResearch#19887 (strip leaked template markers from gemma4 tool args), NousResearch#15369 (strip MEDIA directives), NousResearch#18529 (title leaks <thinking>).
Summary
When using Gemma 4 served via vLLM (NaN Builders, and others), the harmony parser intermittently structures the tool call cleanly —
tool_callsis non-empty and the upstream returnsfinish_reason='tool_calls'— but lets the model's chat-template quote markers (<|"|>in its full and partial forms) escape into the JSON oftool_call.argumentsas literal characters.Hermes' downstream
json.loads(arguments)then fails. The retry sees the same deterministic corruption, fails again, and the run gets surfaced to the user asResponse truncated (finish_reason='length') — model hit max output tokensdespite no token cap being hit and the upstream having returned successfully.This is a complement to the existing content-side recovery path proposed in #7449 — that one fires on
if not tool_calls and content:, assuming the structured channel is safe. This PR handles the case where the structured channel is taken but the JSON insideargumentsis corrupted.Symptom
Captured payload (
kanban_completefrom a worker, gemma4 via NaN Builders, full 1658-byte arg string, captured via SSE pass-through proxy):json.loadschokes at column 43 because"<|\"|"parses as a closed string<|"|followed by a bare2023-09-18token. Same payload retried twice (deterministic), same parse error twice, session aborts.Four marker shapes turn up in the wild:
<|"|>opening (full)"<|\"|""<|"|>closing (full)<|\""<|"|>opening (truncated, no inner\")"<|"<|"|>closing (truncated, no inner\\)<|""Approach
agent/transports/chat_completions.py::normalize_responsealready populatestool_calls. After that loop, if anytool_call.argumentscontains<|and failsjson.loads, run the marker-stripping substitution and accept the result only if the cleaned variant parses. Concretely:The double guard (original unparseable AND cleaned parseable) means already-valid args are never touched, even if they cosmetically contain
<|substrings — legitimate user content (regex with alternation, certain code patterns) can do that.Tests
TestChatCompletionsGemma4ArgsSanitization(6 cases) added totests/agent/transports/test_chat_completions.py:<|present but cleaned variant still unparseable → original left for downstream error visibility.Full test file:
68 passed in 4.11s(61 existing + 7 new).Validation in production
Validated against 14 tool_calls captured from a failing gemma4 + NaN Builders session: 12 already-valid args untouched, 2 broken
kanban_completepayloads (with nestedmetadata.findings) parse cleanly post-sanitization, 0 collateral damage. Worker resumes normally.Relationship to #7449
Independent. #7449 (and the three follow-up issues in its thread) addresses Gemma 4's tool calls landing in
message.contentinstead oftool_calls. This PR addresses the orthogonal case wheretool_callsIS populated but itsargumentsJSON is corrupted. Either fix can land first; both are needed for full coverage.