fix(train-client): filter failed tool calls - #2266
Conversation
renderers records every <tool_call> attempt, marking unknown-name and
malformed ones with a non-OK ToolCallParseStatus rather than dropping
them, and withholds the stop->tool_calls finish-reason promotion for
them ('we keep the attempt visible but deny it OK so consumers agree
with the engine on "no tool was called"'). Engine-side parsers drop the
same calls outright — vLLM's glm45/glm47 run with
validate_tool_names=True — so the chat-completions path never sees them.
response_from_generate promoted any attempt carrying a name into a real
ToolCall, ignoring status. The same bytes therefore behaved differently
on the two paths: on the renderer path a hallucinated tool name became a
recoverable 'error: unknown tool' turn and the episode continued; on the
chat-completions path no tool call was parsed, the harness read the
message as final and ended the episode.
That asymmetry is trainable. In a GLM-4.5-Air SWE run the policy's
turn-1 malformed-call rate drifted 18% -> 56% over 220 steps — nearly
free on the renderer path — while held-out SWE-bench Verified fell 26%
-> 15%, with ~94% of the drop attributable to episodes that died on an
unparsed tool call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
INVALID_JSON and MALFORMED_STRUCTURE describe attempts the engines still emit (vLLM falls back to the raw string and returns the call), so withholding them would make the renderer path stricter than the chat-completions path — the mirror of the bug this fixes. UNKNOWN_TOOL is the one status defined as engine parity, and no parser other than parse_glm sets it, so this is a no-op for renderers that don't validate names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Approved 6e0987d Small, self-contained bug fix that adds a filter to exclude tool calls with failed parse status (UNKNOWN_TOOL) from processing. The author owns this code and the change is defensive in nature. You can customize Macroscope's approvability policy. Learn more. |
|
Superseded by PrimeIntellect-ai/renderers#117 — the fix belongs in renderers, not here. The train client was re-deriving engine parity from renderers#117 makes |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fload One conflict: import adjacency in train.py (main's ToolCallParseStatus from #2266 beside this branch's is_multimodal) — union. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
response_from_generatepromotes any parsed tool call carrying a name into a realToolCall, ignoringToolCallParseStatus:UNKNOWN_TOOLexists specifically to mirror the inference engine. Fromrenderers/parsing.py:renderers/client.pylikewise withholds thestop->tool_callsfinish-reason promotion for it. The train client ignores both signals and hands the call to the agent anyway.Why it matters: train/eval divergence
vLLM's
glm45/glm47parsers run withvalidate_tool_names=Trueand drop unknown-name calls outright. RL train rollouts go through the renderer client, eval rollouts through chat-completions, so identical model output behaves differently:<tool_call>read\n<arg_key>path</arg_key>…(tool not declared)ToolCall-> harness replieserror: unknown tool 'read'-> episode continuesWhy it's trainable
The malformed emission costs about one wasted turn during training and is invisible in the metrics, so nothing penalises it and it rides along on positively-advantaged trajectories. In a GLM-4.5-Air SWE run the policy's turn-1 malformed-call rate drifted 18% -> 56% over 220 steps while held-out SWE-bench Verified fell 26% -> 15% — ~94% of the decline from episodes dying on an unparsed tool call, with the clean subset's solve rate flat (45% -> 44%). With the paths realigned the same setup went 22% -> 49% over 240 steps.
Scope: only
UNKNOWN_TOOLDeliberately not filtering every non-OK status. The other statuses describe attempts the engines still emit, so withholding them would make the renderer path stricter than chat-completions — the mirror of this bug. Concretely, a call whose argument name isn't in the declared schema parses fine but is marked
INVALID_JSONby renderers, while vLLM falls back to the raw string and returns the call:UNKNOWN_TOOLis the one status defined as engine parity, andparse_glmis currently the only parser that sets it — so this is a no-op for the renderers that don't validate names, and correct for any that adopt it later.🤖 Generated with Claude Code
Note
Filter out
UNKNOWN_TOOLtool calls inresponse_from_generateIn train.py,
response_from_generatenow excludes tool calls wheretc.status == ToolCallParseStatus.UNKNOWN_TOOL. If all tool calls are filtered,Response.tool_callsis set toNone.Macroscope summarized 4dc379a.
Note
Low Risk
Single conditional in train client tool-call mapping; no auth or data changes; behavior change is intentional parity for RL rollouts.
Overview
Aligns RL train rollouts with inference/eval by dropping renderer-parsed tool calls marked
ToolCallParseStatus.UNKNOWN_TOOLwhen buildingResponse.tool_callsinresponse_from_generate.Previously, any parsed call with a name was promoted to a
ToolCall, so unrecognized tools (e.g. GLM parsers with name validation) still reached the harness and extended the episode—unlike vLLM/chat-completions paths that drop them. OnlyUNKNOWN_TOOLis filtered; other statuses likeINVALID_JSONstay, so the renderer path does not become stricter than engines for malformed-but-emitted calls.Reviewed by Cursor Bugbot for commit 4dc379a. Bugbot is set up for automated code reviews on this repo. Configure here.