mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-20 13:07:00 +08:00
fix: keep tool call replay when assistant text interleaves call and result
trimReplayDanglingAssistantToolCalls only collected tool results that immediately followed the assistant tool-call message. Models such as gpt-5.3-codex-spark may emit the function_call item before the explanation text within one response, so history replay order becomes assistant[tool_call] -> assistant[text] -> tool[result]. The call was misjudged as dangling and stripped while the tool result survived, producing a function_call_output without a matching function_call that the Responses API rejects with 400. - widen the response collection window to skip interleaved plain assistant text messages, and drop orphan tool results in the same pass - synthesize a placeholder function_call (or drop the output when the tool name is unknown) in normalizeOpenAIResponsesInput so conversations already persisted with corrupted history can resume
This commit is contained in:
@@ -1196,57 +1196,82 @@ func mergeReplayReasoningMetadata(last *modeladapter.Message, current modeladapt
|
||||
}
|
||||
}
|
||||
|
||||
// replayToolResponseWindowEnd 返回 assistant tool-call 消息的响应收集窗口右边界(不含)。
|
||||
// 窗口内除了 tool 结果消息,还允许出现同轮穿插的纯文本 assistant 消息:
|
||||
// 部分模型(如 gpt-5.3-codex-spark)会在同一条响应里先输出 function_call 再输出说明文本,
|
||||
// 落盘顺序为 tool_call → assistant_text → tool_result,若只收集紧邻的 tool 消息,
|
||||
// 会把有结果回放的调用误判为悬空。
|
||||
func replayToolResponseWindowEnd(messages []modeladapter.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 trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []modeladapter.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]modeladapter.Message, 0, len(messages))
|
||||
for index := 0; index < len(messages); index++ {
|
||||
message := cloneReplayModelMessage(messages[index])
|
||||
survivingToolCallIDs := make(map[string]struct{})
|
||||
for index, message := range messages {
|
||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
responded := make(map[string]struct{}, len(message.ToolCalls))
|
||||
for scan := index + 1; scan < replayToolResponseWindowEnd(messages, index); scan++ {
|
||||
if strings.TrimSpace(messages[scan].Role) != "tool" {
|
||||
continue
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(messages[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([]modeladapter.Message, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
message := cloneReplayModelMessage(item)
|
||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||
nextToolCalls := make([]modeladapter.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 && !hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
|
||||
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(messages) && strings.TrimSpace(messages[end].Role) == "tool" {
|
||||
toolCallID := strings.TrimSpace(messages[end].ToolCallID)
|
||||
if toolCallID != "" {
|
||||
responded[toolCallID] = struct{}{}
|
||||
}
|
||||
end++
|
||||
}
|
||||
|
||||
nextToolCalls := make([]modeladapter.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 := cloneReplayModelMessage(messages[toolIndex])
|
||||
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed = append(trimmed, toolMessage)
|
||||
}
|
||||
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || hasReplayableReasoningPayload(message.ReasoningContent, message.ReasoningSignature, message.ReasoningSignatureSource) {
|
||||
message.ToolCalls = nil
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
|
||||
index = end - 1
|
||||
trimmed = append(trimmed, message)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
)
|
||||
|
||||
func replayToolCall(id string, name string) modeladapter.ToolCallDescriptor {
|
||||
return modeladapter.ToolCallDescriptor{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: modeladapter.ToolCallFunctionShape{
|
||||
Name: name,
|
||||
Arguments: "{}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 模型在同一条响应里先输出 function_call 再输出说明文本时,
|
||||
// 历史回放顺序为 assistant[tool_call] -> assistant[text] -> tool[result]。
|
||||
// trimReplayDanglingAssistantToolCalls 需要越过中间的文本消息收集工具结果。
|
||||
func TestTrimReplayDanglingAssistantToolCallsKeepsInterleavedTextResponses(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")}},
|
||||
{Role: "assistant", Content: "我先快速定位上下文"},
|
||||
{Role: "tool", Name: "Grep", ToolCallID: "call_1", Content: "grep result"},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := trimReplayDanglingAssistantToolCalls(messages)
|
||||
if len(trimmed) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 1 || trimmed[1].ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("expected assistant tool call call_1 to survive, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
if trimmed[3].Role != "tool" || trimmed[3].ToolCallID != "call_1" {
|
||||
t.Fatalf("expected tool result call_1 to survive, got %+v", trimmed[3])
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何结果回放的调用仍应被剥离;被剥离调用对应的 tool 结果(若存在)也不得保留。
|
||||
func TestTrimReplayDanglingAssistantToolCallsDropsUnrespondedCallsAndOrphanResults(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{Role: "assistant", ToolCalls: []modeladapter.ToolCallDescriptor{
|
||||
replayToolCall("call_1", "Grep"),
|
||||
replayToolCall("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 := trimReplayDanglingAssistantToolCalls(messages)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
// 调用全部悬空但消息携带可回放 reasoning 时,保留为无调用的 assistant 消息。
|
||||
func TestTrimReplayDanglingAssistantToolCallsKeepsReasoningOnlyShell(t *testing.T) {
|
||||
messages := []modeladapter.Message{
|
||||
{Role: "user", Content: "query"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []modeladapter.ToolCallDescriptor{replayToolCall("call_1", "Grep")},
|
||||
ReasoningSignature: "sig",
|
||||
ReasoningSignatureSource: modeladapter.ReasoningSignatureSourceOpenAIResponses,
|
||||
},
|
||||
{Role: "user", Content: "next"},
|
||||
}
|
||||
|
||||
trimmed := trimReplayDanglingAssistantToolCalls(messages)
|
||||
if len(trimmed) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d: %+v", len(trimmed), trimmed)
|
||||
}
|
||||
if len(trimmed[1].ToolCalls) != 0 {
|
||||
t.Fatalf("expected tool calls to be trimmed, got %+v", trimmed[1].ToolCalls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user