feat(client)!: tool_calls mirrors the engine; attempts move to tool_call_attempts - #117
feat(client)!: tool_calls mirrors the engine; attempts move to tool_call_attempts#117mikasenghaas wants to merge 1 commit into
Conversation
…ll_attempts The generate result returned every parsed attempt under tool_calls, so a consumer wanting the calls an engine would actually have emitted had to re-derive them from ToolCallParseStatus. That put engine-parity knowledge in every consumer instead of in the layer that owns it, and consumers that skipped the check executed calls the chat-completions path drops (vLLM's glm45/glm47 run with validate_tool_names=True), so the same completion behaved differently depending on which client produced it. tool_calls now carries the executable subset the client already computed for the finish-reason promotion. Every attempt, with status, stays on tool_call_attempts for schema-adherence rubrics and selective token masking — the uses engines can't serve, which is why attempts are kept rather than dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d4c2381. Configure here.
| "reasoning_content": parsed.reasoning_content, | ||
| "tool_calls": parsed.tool_calls, | ||
| "tool_calls": emitted_tool_calls, | ||
| "tool_call_attempts": parsed.tool_calls, |
There was a problem hiding this comment.
Existing test asserts old tool_calls contract
Medium Severity
tool_calls now only carries OK attempts, but test_generate_does_not_promote_finish_reason_for_malformed_tool_calls still asserts a single INVALID_JSON entry under tool_calls. With the malformed-only renderer that list is empty now, so the suite fails until the assertion moves to tool_call_attempts.
Reviewed by Cursor Bugbot for commit d4c2381. Configure here.
ApprovabilityVerdict: Needs human review Breaking API change that modifies the contract of the You can customize Macroscope's approvability policy. Learn more. |


Problem
generate()returned every parsed attempt undertool_calls:The client already computes the executable subset for the finish-reason promotion, then discards it. So any consumer that wants "the calls an engine would have emitted" has to re-derive it from
ToolCallParseStatus— putting engine-parity knowledge in every consumer instead of in the layer that owns it.Consumers that don't do that end up executing calls the chat-completions path drops. vLLM's
glm45/glm47parsers run withvalidate_tool_names=True, so an undeclared tool name yields no call from the engine but a liveToolCallfrom this client. In verifiers' train client that difference is load-bearing: on the renderer path a hallucinated tool name became a recoverableerror: unknown tool 'read'turn and the episode continued; through chat-completions the harness saw no tool call, treated 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%, ~94% of the decline from episodes dying on an unparsed tool call.
Change
tool_callsnow carries the executable subset (what an engine would emit). Every attempt, withstatus, moves totool_call_attempts.Attempts are still exposed rather than dropped, because that's the information engines can't give you — schema-adherence rubrics and selective token masking over non-OK spans, per
ParsedToolCall.token_span.This also generalises:
parse_glmis currently the only parser doing name validation, but any family that adopts it inherits parity with no consumer changes.Breaking
Callers reading
result["tool_calls"]for the full attempt record should readtool_call_attempts. Callers that want executable calls — the common case, and what OpenAI semantics imply — need no change and get the correct behaviour by default.Paired with a verifiers change that drops its local status filter and relies on this.
🤖 Generated with Claude Code
Note
Medium Risk
Breaking return-shape change for
tool_callsaffects every consumer ofgenerate(); behavior change is intentional but can break training/verifier code that assumed all attempts lived ontool_calls.Overview
Breaking:
renderers.client.generate()now splits tool-call results so agent loops match chat-completions / vLLM behavior.tool_callscontains onlyToolCallParseStatus.OKentries—the subset an engine would emit (e.g. aftervalidate_tool_names=Trueon GLM parsers). All parsed attempts, including malformed or rejected names, move totool_call_attempts.stop→tool_callsfinish-reason promotion still keys off that same executable subset (emitted_tool_calls).Callers that executed or audited every parse via
tool_callsshould switch totool_call_attempts; callers that only need runnable tools get engine parity without re-filtering.Reviewed by Cursor Bugbot for commit d4c2381. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Split
tool_callsinto executable-only calls and all attempts in client renderertool_callsin the response payload now contains only executable (status == OK) tool calls, mirroring engine behavior.tool_call_attemptsfield in the response that includes all parsed attempts, including malformed or rejected ones.stoptotool_callscontinues to trigger only when at least one executable call is present.tool_callsto contain all parsed attempts (including non-OK ones) must switch totool_call_attempts.Macroscope summarized d4c2381.