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:
上玄
2026-08-14 16:51:45 +08:00
parent a3ec2a0dfc
commit 5d04b5b08b
5 changed files with 279 additions and 43 deletions
+24
View File
@@ -1956,6 +1956,7 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
instructionParts := make([]string, 0, 2)
items := make([]map[string]any, 0, len(messages))
responsesCallIDs := make(map[string]string)
emittedCallIDs := make(map[string]struct{})
activeAssistantReasoningKey := ""
for _, message := range messages {
role := strings.TrimSpace(message.Role)
@@ -1968,6 +1969,26 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
}
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
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)
if hasImageContentParts(message.ContentParts) {
content, err := openAIResponsesMessageContent(message, false)
@@ -2034,6 +2055,9 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
toolItem["status"] = "completed"
}
items = append(items, toolItem)
if strings.TrimSpace(callID) != "" {
emittedCallIDs[strings.TrimSpace(callID)] = struct{}{}
}
}
}
}