Skip to content

feat(client)!: tool_calls mirrors the engine; attempts move to tool_call_attempts - #117

Open
mikasenghaas wants to merge 1 commit into
mainfrom
mika/emitted-tool-calls
Open

feat(client)!: tool_calls mirrors the engine; attempts move to tool_call_attempts#117
mikasenghaas wants to merge 1 commit into
mainfrom
mika/emitted-tool-calls

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 6, 2026

Copy link
Copy Markdown
Member

Problem

generate() returned every parsed attempt under tool_calls:

ok_tool_calls = [tc for tc in parsed.tool_calls if tc.status == ToolCallParseStatus.OK]
if ok_tool_calls and finish_reason == "stop":
    finish_reason = "tool_calls"
return {..., "tool_calls": parsed.tool_calls, ...}   # all attempts

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/glm47 parsers run with validate_tool_names=True, so an undeclared tool name yields no call from the engine but a live ToolCall from this client. In verifiers' train client that difference is load-bearing: on the renderer path a hallucinated tool name became a recoverable error: 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_calls now carries the executable subset (what an engine would emit). Every attempt, with status, moves to tool_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_glm is 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 read tool_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_calls affects every consumer of generate(); behavior change is intentional but can break training/verifier code that assumed all attempts lived on tool_calls.

Overview
Breaking: renderers.client.generate() now splits tool-call results so agent loops match chat-completions / vLLM behavior.

tool_calls contains only ToolCallParseStatus.OK entries—the subset an engine would emit (e.g. after validate_tool_names=True on GLM parsers). All parsed attempts, including malformed or rejected names, move to tool_call_attempts. stoptool_calls finish-reason promotion still keys off that same executable subset (emitted_tool_calls).

Callers that executed or audited every parse via tool_calls should switch to tool_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_calls into executable-only calls and all attempts in client renderer

  • tool_calls in the response payload now contains only executable (status == OK) tool calls, mirroring engine behavior.
  • Introduces a new tool_call_attempts field in the response that includes all parsed attempts, including malformed or rejected ones.
  • Finish reason promotion from stop to tool_calls continues to trigger only when at least one executable call is present.
  • Behavioral Change: Callers relying on tool_calls to contain all parsed attempts (including non-OK ones) must switch to tool_call_attempts.

Macroscope summarized d4c2381.

…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread renderers/client.py
"reasoning_content": parsed.reasoning_content,
"tool_calls": parsed.tool_calls,
"tool_calls": emitted_tool_calls,
"tool_call_attempts": parsed.tool_calls,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d4c2381. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 6, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

Breaking API change that modifies the contract of the tool_calls field. An unresolved review comment identifies that existing tests assert the old behavior and will fail until updated.

You can customize Macroscope's approvability policy. Learn more.

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.

1 participant