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
This commit is contained in:
viettranx committed 2026-04-15 12:39:56 +07:00
1 parent 5c229bed59
commit 6544999779
1 file changed
+37 -2
+37 -2
View File
@@ -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
}