From b7f8237f3b1f262c16a31c8f387a037ff599ce09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E7=8E=84?= <1421148240@qq.com> Date: Fri, 14 Aug 2026 19:59:47 +0800 Subject: [PATCH] fix: keep tool call in provider sanitize when text interleaves call and result trimDanglingAssistantToolCalls in the provider router held the same consecutive-tool-message assumption as the replay projector: when a model emits function_call items before explanation text within one response (e.g. gpt-5.3-codex-spark), the sanitized sequence becomes assistant[tool_call] -> assistant[text] -> tool[result] and responded calls were stripped at the provider boundary even though the projector already replayed them correctly. The orphan tool result then forced the Responses adapter to synthesize a placeholder function_call with empty arguments, degrading the arguments the model sees on in-loop requests. Widen the response collection window to skip interleaved plain assistant text messages and drop orphan tool results, mirroring the projector fix. --- internal/backend/agent/model/router.go | 105 +++++++++++------- .../model/router_dangling_tool_call_test.go | 77 +++++++++++++ 2 files changed, 142 insertions(+), 40 deletions(-) create mode 100644 internal/backend/agent/model/router_dangling_tool_call_test.go diff --git a/internal/backend/agent/model/router.go b/internal/backend/agent/model/router.go index 787c22e..7b9b991 100644 --- a/internal/backend/agent/model/router.go +++ b/internal/backend/agent/model/router.go @@ -339,57 +339,82 @@ func mergeProviderReasoningMetadata(last *Message, current Message) { } } +// providerToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。 +// 窗口内除 tool 结果消息外,还允许出现同轮穿插的纯文本 assistant 消息: +// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本, +// 回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result],若只收集紧邻的 +// tool 消息,会把有结果回放的调用误判为悬空。 +func providerToolResponseWindowEnd(messages []Message, index int) int { + end := index + 1 + for end < len(messages) { + candidate := messages[end] + switch { + case strings.TrimSpace(candidate.Role) == "tool": + end++ + case strings.TrimSpace(candidate.Role) == "assistant" && len(candidate.ToolCalls) == 0: + end++ + default: + return end + } + } + return end +} + func trimDanglingAssistantToolCalls(input []Message) []Message { if len(input) == 0 { return nil } - trimmed := make([]Message, 0, len(input)) - for index := 0; index < len(input); index++ { - message := cloneProviderMessage(input[index]) + survivingToolCallIDs := make(map[string]struct{}) + for index, message := range input { if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 { + continue + } + responded := make(map[string]struct{}, len(message.ToolCalls)) + for scan := index + 1; scan < providerToolResponseWindowEnd(input, index); scan++ { + if strings.TrimSpace(input[scan].Role) != "tool" { + continue + } + if toolCallID := strings.TrimSpace(input[scan].ToolCallID); toolCallID != "" { + responded[toolCallID] = struct{}{} + } + } + for _, toolCall := range message.ToolCalls { + if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" { + if _, ok := responded[toolCallID]; ok { + survivingToolCallIDs[toolCallID] = struct{}{} + } + } + } + } + trimmed := make([]Message, 0, len(input)) + for _, item := range input { + message := cloneProviderMessage(item) + if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 { + nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls)) + for _, toolCall := range message.ToolCalls { + if _, ok := survivingToolCallIDs[strings.TrimSpace(toolCall.ID)]; !ok { + continue + } + toolCall.Index = len(nextToolCalls) + nextToolCalls = append(nextToolCalls, toolCall) + } + if len(nextToolCalls) == 0 { + if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && strings.TrimSpace(message.ReasoningContent) == "" { + continue + } + message.ToolCalls = nil + } else { + message.ToolCalls = nextToolCalls + } trimmed = append(trimmed, message) continue } - - end := index + 1 - responded := make(map[string]struct{}, len(message.ToolCalls)) - for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" { - toolCallID := strings.TrimSpace(input[end].ToolCallID) - if toolCallID != "" { - responded[toolCallID] = struct{}{} - } - end++ - } - - nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls)) - allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls)) - for _, toolCall := range message.ToolCalls { - toolCallID := strings.TrimSpace(toolCall.ID) - if _, ok := responded[toolCallID]; !ok { + if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" { + if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok { continue } - item := toolCall - item.Index = len(nextToolCalls) - nextToolCalls = append(nextToolCalls, item) - allowedToolCallIDs[toolCallID] = struct{}{} } - - if len(nextToolCalls) > 0 { - message.ToolCalls = nextToolCalls - trimmed = append(trimmed, message) - for toolIndex := index + 1; toolIndex < end; toolIndex++ { - toolMessage := cloneProviderMessage(input[toolIndex]) - if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok { - continue - } - trimmed = append(trimmed, toolMessage) - } - } else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" { - message.ToolCalls = nil - trimmed = append(trimmed, message) - } - - index = end - 1 + trimmed = append(trimmed, message) } return trimmed } diff --git a/internal/backend/agent/model/router_dangling_tool_call_test.go b/internal/backend/agent/model/router_dangling_tool_call_test.go new file mode 100644 index 0000000..fa19ada --- /dev/null +++ b/internal/backend/agent/model/router_dangling_tool_call_test.go @@ -0,0 +1,77 @@ +package modeladapter + +import "testing" + +func routerToolCall(id string, name string) ToolCallDescriptor { + return ToolCallDescriptor{ + ID: id, + Type: "function", + Function: ToolCallFunctionShape{ + Name: name, + Arguments: `{"pattern":"ref"}`, + }, + } +} + +// 模型在同一条响应里先输出 function_call 再输出说明文本时, +// sanitize 后的消息序列为 assistant[tool_call] -> assistant[text] -> tool[result]。 +// trimDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果, +// 否则会产生孤儿 function_call_output(Responses API 400)。 +func TestTrimDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) { + input := []Message{ + {Role: "user", Content: "query"}, + { + Role: "assistant", + ToolCalls: []ToolCallDescriptor{ + routerToolCall("call_1", "Grep"), + routerToolCall("call_2", "Read"), + }, + }, + {Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"}, + {Role: "assistant", Content: "我先快速定位上下文"}, + {Role: "tool", Name: "Read", ToolCallID: "call_2", Content: "read result"}, + {Role: "user", Content: "next"}, + } + + trimmed := sanitizeProviderMessages(input) + if len(trimmed) != 6 { + t.Fatalf("expected 6 messages, got %d: %+v", len(trimmed), trimmed) + } + if len(trimmed[1].ToolCalls) != 2 { + t.Fatalf("expected both tool calls to survive, got %+v", trimmed[1].ToolCalls) + } + if trimmed[3].Role != "assistant" || trimmed[3].Content == "" { + t.Fatalf("expected interleaved assistant text to survive, got %+v", trimmed[3]) + } + if trimmed[4].ToolCallID != "call_2" { + t.Fatalf("expected call_2 result to survive, got %+v", trimmed[4]) + } +} + +// 完全没有结果回放的调用仍应被剥离,且孤儿 tool 结果不得保留。 +func TestTrimDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) { + input := []Message{ + {Role: "user", Content: "query"}, + { + Role: "assistant", + ToolCalls: []ToolCallDescriptor{ + routerToolCall("call_1", "Grep"), + routerToolCall("call_2", "Read"), + }, + }, + {Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"}, + {Role: "tool", Name: "Read", ToolCallID: "call_3", Content: "orphan result"}, + {Role: "user", Content: "next"}, + } + + trimmed := sanitizeProviderMessages(input) + if len(trimmed) != 4 { + t.Fatalf("expected 4 messages, got %d: %+v", len(trimmed), trimmed) + } + if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" { + t.Fatalf("expected only call_1 to survive, got %+v", trimmed[1].ToolCalls) + } + if trimmed[2].ToolCallID != "call_1" { + t.Fatalf("expected only call_1 result to survive, got %+v", trimmed[2]) + } +}