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 {
|
func trimDanglingAssistantToolCalls(input []Message) []Message {
|
||||||
if len(input) == 0 {
|
if len(input) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
trimmed := make([]Message, 0, len(input))
|
survivingToolCallIDs := make(map[string]struct{})
|
||||||
for index := 0; index < len(input); index++ {
|
for index, message := range input {
|
||||||
message := cloneProviderMessage(input[index])
|
|
||||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||||
trimmed = append(trimmed, message)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
end := index + 1
|
|
||||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||||
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
|
for scan := index + 1; scan < providerToolResponseWindowEnd(input, index); scan++ {
|
||||||
toolCallID := strings.TrimSpace(input[end].ToolCallID)
|
if strings.TrimSpace(input[scan].Role) != "tool" {
|
||||||
if toolCallID != "" {
|
continue
|
||||||
|
}
|
||||||
|
if toolCallID := strings.TrimSpace(input[scan].ToolCallID); toolCallID != "" {
|
||||||
responded[toolCallID] = struct{}{}
|
responded[toolCallID] = struct{}{}
|
||||||
}
|
}
|
||||||
end++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
|
|
||||||
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
|
|
||||||
for _, toolCall := range message.ToolCalls {
|
for _, toolCall := range message.ToolCalls {
|
||||||
toolCallID := strings.TrimSpace(toolCall.ID)
|
if toolCallID := strings.TrimSpace(toolCall.ID); toolCallID != "" {
|
||||||
if _, ok := responded[toolCallID]; !ok {
|
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
|
continue
|
||||||
}
|
}
|
||||||
item := toolCall
|
toolCall.Index = len(nextToolCalls)
|
||||||
item.Index = len(nextToolCalls)
|
nextToolCalls = append(nextToolCalls, toolCall)
|
||||||
nextToolCalls = append(nextToolCalls, item)
|
|
||||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
if len(nextToolCalls) == 0 {
|
||||||
if len(nextToolCalls) > 0 {
|
if strings.TrimSpace(message.Content) == "" && len(message.ContentParts) == 0 && strings.TrimSpace(message.ReasoningContent) == "" {
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
trimmed = append(trimmed, toolMessage)
|
|
||||||
}
|
|
||||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
|
|
||||||
message.ToolCalls = nil
|
message.ToolCalls = nil
|
||||||
trimmed = append(trimmed, message)
|
} else {
|
||||||
|
message.ToolCalls = nextToolCalls
|
||||||
}
|
}
|
||||||
|
trimmed = append(trimmed, message)
|
||||||
index = end - 1
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||||
|
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed = append(trimmed, message)
|
||||||
}
|
}
|
||||||
return trimmed
|
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