fix(agent): occurrence-aware tool call id sanitization

This commit is contained in:
Badgerbees 2026-03-21 16:19:26 +07:00
parent 6148ccc529
commit f390b6686e
4 changed files with 107 additions and 40 deletions

View file

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

View file

@ -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))

View file

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

View file

@ -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))