From d4c238162857f65e9ab09f4b00782c070b66dcfc Mon Sep 17 00:00:00 2001 From: Mika Senghaas Date: Thu, 6 Aug 2026 05:39:52 +0000 Subject: [PATCH] feat(client): tool_calls mirrors the engine; attempts move to tool_call_attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- renderers/client.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/renderers/client.py b/renderers/client.py index 5196f4a..5c4b68b 100644 --- a/renderers/client.py +++ b/renderers/client.py @@ -362,19 +362,23 @@ def _prepare(): routed_experts = choice.get("routed_experts") kept_tokens = choice.get("kept_tokens") + # An engine's chat-completions endpoint only ever emits calls it accepted: + # vLLM's glm45/glm47 parsers run with ``validate_tool_names=True`` and drop + # the rest. ``tool_calls`` mirrors that — the calls a caller should execute — + # so an agent loop behaves the same whether it generated through this client + # or through the engine. Every attempt, accepted or not, stays available on + # ``tool_call_attempts`` for the uses engines can't serve: schema-adherence + # rubrics and selective token masking over non-OK spans. + # # /inference/v1/generate returns finish_reason in {"stop","length",...} — # never "tool_calls" (a chat-completions concept). Promote stop→tool_calls - # when we extracted at least one well-formed tool call client-side, so - # OpenAI-compatible agent loops continue past the tool turn instead of - # treating the response as final. Malformed attempts (INVALID_JSON, - # UNCLOSED_BLOCK, ...) don't qualify — those still surface on - # ``parsed.tool_calls`` so verifiers can inspect them, but they don't - # trigger the tool-loop continuation. + # when we extracted at least one executable call, so OpenAI-compatible agent + # loops continue past the tool turn instead of treating the response as final. finish_reason = choice.get("finish_reason") - ok_tool_calls = [ + emitted_tool_calls = [ tc for tc in parsed.tool_calls if tc.status == ToolCallParseStatus.OK ] - if ok_tool_calls and finish_reason == "stop": + if emitted_tool_calls and finish_reason == "stop": finish_reason = "tool_calls" return { @@ -384,7 +388,8 @@ def _prepare(): "completion_logprobs": completion_logprobs, "content": parsed.content, "reasoning_content": parsed.reasoning_content, - "tool_calls": parsed.tool_calls, + "tool_calls": emitted_tool_calls, + "tool_call_attempts": parsed.tool_calls, "finish_reason": finish_reason, "routed_experts": routed_experts, "kept_tokens": kept_tokens,