From 654499977971e9b80bfd2ef6d371e54c9dc6d881 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 15 Apr 2026 12:39:56 +0700 Subject: [PATCH] fix(pipeline): treat gemini tool_calls+empty-args as truncation retry Gemini returns finish_reason="tool_calls" (not "length") even when the thinking budget exhausted max_tokens before tool arguments could be emitted. Extend the existing truncation retry condition to detect this case via an allowlist of mutating tools (write_file, edit, exec, create_image, read_file) that virtually never legitimately call with empty args. Nullary tools (datetime, heartbeat) pass through unchanged. Trace: 019d8f33-2de1-7ab2-9a32-9df92cd610dd --- internal/pipeline/think_stage.go | 39 ++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/think_stage.go b/internal/pipeline/think_stage.go index 71e473a1..f80afb1f 100644 --- a/internal/pipeline/think_stage.go +++ b/internal/pipeline/think_stage.go @@ -69,9 +69,14 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error { state.Think.TotalUsage.ThinkingTokens += resp.Usage.ThinkingTokens } - // 6. Handle truncation: only retry when tool call args are truncated or malformed. + // 6. Handle truncation: retry when tool call args are truncated or malformed. + // Gemini returns finish_reason="tool_calls" (not "length") even when the thinking + // budget exhausted max_tokens before args could be emitted — detect via empty + // args on allowlisted mutating tools. Nullary tools (datetime, heartbeat) skip + // the heuristic so their legitimate empty-args calls pass through. // Text-only truncation (no tool calls) is a valid long answer — deliver it. - truncated := resp.FinishReason == "length" && len(resp.ToolCalls) > 0 + truncated := len(resp.ToolCalls) > 0 && (resp.FinishReason == "length" || + (resp.FinishReason == "tool_calls" && toolCallsHaveMissingRequiredArgs(resp.ToolCalls))) parseErr := !truncated && toolCallsHaveParseErrors(resp.ToolCalls) if truncated || parseErr { state.Think.TruncRetries++ @@ -157,3 +162,33 @@ func toolCallsHaveParseErrors(calls []providers.ToolCall) bool { } return false } + +// mutatingToolsRequireArgs is the static allowlist of tools where empty +// arguments are virtually never legitimate. Production telemetry (30d) shows +// 1/211 tool_call spans had empty args (the Gemini-3 budget-exhaustion trace); +// datetime/heartbeat/web_search always carry args. Conservative scope — expand +// only with telemetry justification. +var mutatingToolsRequireArgs = map[string]struct{}{ + "write_file": {}, + "edit": {}, + "exec": {}, + "create_image": {}, + "read_file": {}, +} + +// toolCallsHaveMissingRequiredArgs returns true when any call in the batch +// targets a mutating tool from the allowlist but carries empty Arguments. +// This is the Gemini-3 truncation signal: finish_reason="tool_calls" with +// len(args)==0 on a tool we know requires params means the budget ran out +// before args could be emitted. +func toolCallsHaveMissingRequiredArgs(calls []providers.ToolCall) bool { + for _, tc := range calls { + if _, requires := mutatingToolsRequireArgs[tc.Name]; !requires { + continue + } + if len(tc.Arguments) == 0 { + return true + } + } + return false +}