mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-20 04:57:01 +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:
@@ -1956,6 +1956,7 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
|||||||
instructionParts := make([]string, 0, 2)
|
instructionParts := make([]string, 0, 2)
|
||||||
items := make([]map[string]any, 0, len(messages))
|
items := make([]map[string]any, 0, len(messages))
|
||||||
responsesCallIDs := make(map[string]string)
|
responsesCallIDs := make(map[string]string)
|
||||||
|
emittedCallIDs := make(map[string]struct{})
|
||||||
activeAssistantReasoningKey := ""
|
activeAssistantReasoningKey := ""
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
role := strings.TrimSpace(message.Role)
|
role := strings.TrimSpace(message.Role)
|
||||||
@@ -1968,6 +1969,26 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
|||||||
}
|
}
|
||||||
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||||
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
|
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
|
||||||
|
if strings.TrimSpace(callID) != "" {
|
||||||
|
if _, ok := emittedCallIDs[callID]; !ok {
|
||||||
|
// 历史损坏时可能出现没有配对 function_call 的工具结果
|
||||||
|
// (例如旧版本回放逻辑剥离了 assistant 调用但保留了结果)。
|
||||||
|
// Responses API 会直接拒绝这种 input,这里补一个占位 function_call
|
||||||
|
// 让旧会话可以继续;无法补齐时丢弃该结果。
|
||||||
|
if name := strings.TrimSpace(message.Name); name != "" {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": callID,
|
||||||
|
"name": name,
|
||||||
|
"arguments": "{}",
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
emittedCallIDs[callID] = struct{}{}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var output any = openAIResponsesMessageText(message)
|
var output any = openAIResponsesMessageText(message)
|
||||||
if hasImageContentParts(message.ContentParts) {
|
if hasImageContentParts(message.ContentParts) {
|
||||||
content, err := openAIResponsesMessageContent(message, false)
|
content, err := openAIResponsesMessageContent(message, false)
|
||||||
@@ -2034,6 +2055,9 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
|||||||
toolItem["status"] = "completed"
|
toolItem["status"] = "completed"
|
||||||
}
|
}
|
||||||
items = append(items, toolItem)
|
items = append(items, toolItem)
|
||||||
|
if strings.TrimSpace(callID) != "" {
|
||||||
|
emittedCallIDs[strings.TrimSpace(callID)] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package modeladapter
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 历史损坏时(旧版本回放逻辑剥离了 assistant 调用但保留了结果),
|
||||||
|
// function_call_output 会缺少配对的 function_call,Responses API 会拒绝。
|
||||||
|
// 这里验证 adapter 会为孤儿结果补一个占位 function_call,让旧会话可以继续。
|
||||||
|
func TestNormalizeOpenAIResponsesInputSynthesizesCallForOrphanToolOutput(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "query"},
|
||||||
|
{Role: "assistant", Content: "我先快速定位上下文"},
|
||||||
|
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
|
||||||
|
{Role: "user", Content: "next"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var callIndexes []int
|
||||||
|
var outputIndexes []int
|
||||||
|
for index, item := range items {
|
||||||
|
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
|
||||||
|
callIndexes = append(callIndexes, index)
|
||||||
|
}
|
||||||
|
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
|
||||||
|
outputIndexes = append(outputIndexes, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(callIndexes) != 1 {
|
||||||
|
t.Fatalf("expected 1 synthesized function_call, got %d: %+v", len(callIndexes), items)
|
||||||
|
}
|
||||||
|
if len(outputIndexes) != 1 || outputIndexes[0] != callIndexes[0]+1 {
|
||||||
|
t.Fatalf("expected function_call_output right after synthesized function_call, calls=%v outputs=%v", callIndexes, outputIndexes)
|
||||||
|
}
|
||||||
|
if got := items[callIndexes[0]]["name"]; got != "Grep" {
|
||||||
|
t.Fatalf("expected synthesized function_call name Grep, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连工具名都没有的孤儿结果只能丢弃,避免上游 400。
|
||||||
|
func TestNormalizeOpenAIResponsesInputDropsNamelessOrphanToolOutput(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "query"},
|
||||||
|
{Role: "tool", ToolCallID: "call_unknown", Content: "orphan result"},
|
||||||
|
{Role: "user", Content: "next"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item["type"] == "function_call_output" {
|
||||||
|
t.Fatalf("expected orphan function_call_output to be dropped, got %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常配对的调用与结果不应受防御逻辑影响。
|
||||||
|
func TestNormalizeOpenAIResponsesInputKeepsPairedCallAndOutput(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "query"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []ToolCallDescriptor{{
|
||||||
|
ID: "call_xrN6",
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunctionShape{
|
||||||
|
Name: "Grep",
|
||||||
|
Arguments: `{"pattern":"ref"}`,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{Role: "tool", Name: "Grep", ToolCallID: "call_xrN6", Content: "grep result"},
|
||||||
|
{Role: "user", Content: "next"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, items, err := normalizeOpenAIResponsesInput(messages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeOpenAIResponsesInput failed: %v", err)
|
||||||
|
}
|
||||||
|
var calls, outputs int
|
||||||
|
for _, item := range items {
|
||||||
|
if item["type"] == "function_call" && item["call_id"] == "call_xrN6" {
|
||||||
|
calls++
|
||||||
|
}
|
||||||
|
if item["type"] == "function_call_output" && item["call_id"] == "call_xrN6" {
|
||||||
|
outputs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 || outputs != 1 {
|
||||||
|
t.Fatalf("expected exactly one paired function_call/output, got calls=%d outputs=%d", calls, outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,14 @@ func TestToolImageProviderEncodings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
|
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(items) != 1 || items[0]["type"] != "function_call_output" {
|
// 孤儿 tool 结果(无前置 assistant 调用)会补一个占位 function_call,
|
||||||
|
// 保证每个 function_call_output 都有配对调用。
|
||||||
|
if len(items) != 2 || items[0]["type"] != "function_call" || items[0]["call_id"] != "call-1" || items[1]["type"] != "function_call_output" {
|
||||||
t.Fatalf("openai responses items = %#v", items)
|
t.Fatalf("openai responses items = %#v", items)
|
||||||
}
|
}
|
||||||
content, ok := items[0]["output"].([]map[string]any)
|
content, ok := items[1]["output"].([]map[string]any)
|
||||||
if !ok || len(content) != 2 {
|
if !ok || len(content) != 2 {
|
||||||
t.Fatalf("openai responses output = %#v", items[0]["output"])
|
t.Fatalf("openai responses output = %#v", items[1]["output"])
|
||||||
}
|
}
|
||||||
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
|
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
|
||||||
t.Fatalf("openai responses content = %#v", content)
|
t.Fatalf("openai responses content = %#v", content)
|
||||||
|
|||||||
@@ -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 {
|
func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []modeladapter.Message {
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
trimmed := make([]modeladapter.Message, 0, len(messages))
|
survivingToolCallIDs := make(map[string]struct{})
|
||||||
for index := 0; index < len(messages); index++ {
|
for index, message := range messages {
|
||||||
message := cloneReplayModelMessage(messages[index])
|
|
||||||
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
|
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)
|
trimmed = append(trimmed, message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(message.Role) == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||||
end := index + 1
|
if _, ok := survivingToolCallIDs[strings.TrimSpace(message.ToolCallID)]; !ok {
|
||||||
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 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := toolCall
|
|
||||||
item.Index = len(nextToolCalls)
|
|
||||||
nextToolCalls = append(nextToolCalls, item)
|
|
||||||
allowedToolCallIDs[toolCallID] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
trimmed = append(trimmed, message)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return trimmed
|
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