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.
This commit is contained in:
上玄
2026-08-14 19:59:47 +08:00
parent 5d04b5b08b
commit b7f8237f3b
2 changed files with 142 additions and 40 deletions
+65 -40
View File
@@ -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
}
@@ -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_outputResponses 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])
}
}