Skip to content

fix(train-client): filter failed tool calls - #2266

Merged
mikasenghaas merged 4 commits into
mainfrom
mika/train-client-ok-status-filter
Aug 6, 2026
Merged

fix(train-client): filter failed tool calls#2266
mikasenghaas merged 4 commits into
mainfrom
mika/train-client-ok-status-filter

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 6, 2026

Copy link
Copy Markdown
Member

Problem

response_from_generate promotes any parsed tool call carrying a name into a real ToolCall, ignoring ToolCallParseStatus:

for i, tc in enumerate(result.get("tool_calls") or [])
if getattr(tc, "name", None)

UNKNOWN_TOOL exists specifically to mirror the inference engine. From renderers/parsing.py:

# vLLM >= 0.24 drops the call entirely here; we keep the
# attempt visible but deny it OK so consumers agree with
# the engine on "no tool was called."
status = ToolCallParseStatus.UNKNOWN_TOOL

renderers/client.py likewise withholds the stop -> tool_calls finish-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/glm47 parsers run with validate_tool_names=True and drop unknown-name calls outright. RL train rollouts go through the renderer client, eval rollouts through chat-completions, so identical model output behaves differently:

renderer path (train) chat-completions path (eval)
<tool_call>read\n<arg_key>path</arg_key>… (tool not declared) becomes a ToolCall -> harness replies error: unknown tool 'read' -> episode continues engine drops it -> no tool call -> harness reads the message as final -> episode ends

Why 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_TOOL

Deliberately 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_JSON by renderers, while vLLM falls back to the raw string and returns the call:

undeclared arg 'cmd'  ->  name=bash  status=invalid_json  args={'cmd': 'pwd'}

UNKNOWN_TOOL is the one status defined as engine parity, and parse_glm is 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_TOOL tool calls in response_from_generate

In train.py, response_from_generate now excludes tool calls where tc.status == ToolCallParseStatus.UNKNOWN_TOOL. If all tool calls are filtered, Response.tool_calls is set to None.

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_TOOL when building Response.tool_calls in response_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. Only UNKNOWN_TOOL is filtered; other statuses like INVALID_JSON stay, 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.

mikasenghaas and others added 2 commits August 6, 2026 05:19
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>
@mikasenghaas mikasenghaas changed the title fix(train-client): only OK-status tool calls reach the agent fix(train-client): filter failed tool calls Aug 6, 2026
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>
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 6, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 6, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

@mikasenghaas

Copy link
Copy Markdown
Member Author

Superseded by PrimeIntellect-ai/renderers#117 — the fix belongs in renderers, not here.

The train client was re-deriving engine parity from ToolCallParseStatus, which puts that knowledge in every consumer instead of the layer that owns it (and, as it turned out, is easy to get wrong: filtering every non-OK status also drops calls the engine does emit, e.g. a valid call whose argument name isn't in the declared schema comes back INVALID_JSON while vLLM falls back to the raw string and returns it).

renderers#117 makes tool_calls the executable subset the client already computes for the finish-reason promotion, and moves the full attempt record to tool_call_attempts. With that, this filter is unnecessary — response_from_generate stays as it is on main and gets the correct behaviour by default, for every renderer family rather than just GLM. Closing.

@mikasenghaas mikasenghaas reopened this Aug 6, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikasenghaas
mikasenghaas merged commit b23b5c2 into main Aug 6, 2026
3 of 6 checks passed
@mikasenghaas
mikasenghaas deleted the mika/train-client-ok-status-filter branch August 6, 2026 19:41
eligotts added a commit that referenced this pull request Aug 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants