diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 8db8f0b5e..9f7bb2358 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -708,6 +708,56 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message final = append(final, msg) } + // Third pass (occurrence-aware): ensure all tool_call_id values are unique + // across the entire transcript. This prevents 400 errors from strict + // non-OpenAI providers when the same ID appears in different turns or + // is duplicated within a single assistant message. + type callResolver struct { + rewritten []string + usedCount int + } + resolver := make(map[string]*callResolver) + globalUsed := make(map[string]bool) + + for i := range final { + msg := &final[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + for j := range msg.ToolCalls { + originalID := msg.ToolCalls[j].ID + if _, ok := resolver[originalID]; !ok { + resolver[originalID] = &callResolver{} + } + res := resolver[originalID] + occ := len(res.rewritten) + + rewrittenID := originalID + // If ID was used globally or this is an occurrence repeat, make it unique. + if occ > 0 || globalUsed[rewrittenID] { + rewrittenID = fmt.Sprintf("%s:%d", originalID, occ) + for globalUsed[rewrittenID] { + // Extremely unlikely collision with another ID's suffix + rewrittenID += "_x" + } + } + + msg.ToolCalls[j].ID = rewrittenID + res.rewritten = append(res.rewritten, rewrittenID) + globalUsed[rewrittenID] = true + } + } else if msg.Role == "tool" { + originalID := msg.ToolCallID + if res, ok := resolver[originalID]; ok && res.usedCount < len(res.rewritten) { + msg.ToolCallID = res.rewritten[res.usedCount] + res.usedCount++ + } else { + // Orphan result: assign unique fallback ID but preserve original prefix for context. + fallback := fmt.Sprintf("orphan_%s_%d", originalID, i) + msg.ToolCallID = fallback + globalUsed[fallback] = true + } + } + } + return final } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed5c73afc..65dce53a9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1291,10 +1291,7 @@ func (al *AgentLoop) runLLMIteration( streamer.Cancel(ctx) } - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } + normalizedToolCalls := providers.NormalizeToolCall(response.ToolCalls) // Log tool calls toolNames := make([]string, 0, len(normalizedToolCalls)) diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index a33e1eb5c..5e6f1ca2c 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -46,45 +46,68 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) // and ensures both are populated consistently. -func NormalizeToolCall(tc ToolCall) ToolCall { - normalized := tc - - // Ensure Name is populated from Function if not set - if normalized.Name == "" && normalized.Function != nil { - normalized.Name = normalized.Function.Name +// (Addition: It also ensures that each tool call ID is unique across the set for strict providers.) +func NormalizeToolCall(calls []ToolCall) []ToolCall { + if len(calls) == 0 { + return calls } - // Ensure Arguments is not nil - if normalized.Arguments == nil { - normalized.Arguments = map[string]any{} - } + used := make(map[string]bool) + sanitized := make([]ToolCall, 0, len(calls)) - // Parse Arguments from Function.Arguments if not already set - if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { - normalized.Arguments = parsed - } - } + for i, tc := range calls { + normalized := tc - // Ensure Function is populated with consistent values - argsJSON, _ := json.Marshal(normalized.Arguments) - if normalized.Function == nil { - normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), - } - } else { - if normalized.Function.Name == "" { - normalized.Function.Name = normalized.Name - } - if normalized.Name == "" { + // Ensure Name is populated from Function if not set + if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name } - if normalized.Function.Arguments == "" { - normalized.Function.Arguments = string(argsJSON) + + // Ensure Arguments is not nil + if normalized.Arguments == nil { + normalized.Arguments = map[string]any{} } + + // Parse Arguments from Function.Arguments if not already set + if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { + normalized.Arguments = parsed + } + } + + // Ensure Function is populated with consistent values + argsJSON, _ := json.Marshal(normalized.Arguments) + if normalized.Function == nil { + normalized.Function = &FunctionCall{ + Name: normalized.Name, + Arguments: string(argsJSON), + } + } else { + if normalized.Function.Name == "" { + normalized.Function.Name = normalized.Name + } + if normalized.Name == "" { + normalized.Name = normalized.Function.Name + } + if normalized.Function.Arguments == "" { + normalized.Function.Arguments = string(argsJSON) + } + } + + // ID uniqueness normalization fix + id := strings.TrimSpace(normalized.ID) + if id == "" || used[id] { + id = fmt.Sprintf("call_auto_%d", i) + for used[id] { + id += "_x" + } + } + used[id] = true + normalized.ID = id + + sanitized = append(sanitized, normalized) } - return normalized + return sanitized } diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 244f0d4a2..957a02bbd 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -85,10 +85,7 @@ func RunToolLoop( break } - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } + normalizedToolCalls := providers.NormalizeToolCall(response.ToolCalls) // 5. Log tool calls toolNames := make([]string, 0, len(normalizedToolCalls))