mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-20 04:57:01 +08:00
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:
@@ -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_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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user