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 +}