fix: enforce strict tool-call pairing across chat paths

This commit is contained in:
DBT
2026-02-26 05:47:35 +00:00
parent dea7f361f2
commit f8c662a468
4 changed files with 167 additions and 47 deletions

View File

@@ -66,19 +66,6 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
if statusCode != http.StatusOK {
preview := previewResponseBody(body)
if statusCode == http.StatusBadRequest && strings.Contains(strings.ToLower(preview), "no tool call found for function call output") {
// Retry once with sanitized history to avoid orphaned tool outputs causing hard-fail.
safeMessages := sanitizeResponsesRetryMessages(messages)
body2, status2, ctype2, err2 := p.callResponses(ctx, safeMessages, tools, model, options)
if err2 == nil && status2 == http.StatusOK && json.Valid(body2) {
logger.InfoCF("provider", "Recovered responses 400 by sanitizing tool outputs", map[string]interface{}{"messages_before": len(messages), "messages_after": len(safeMessages)})
return parseResponsesAPIResponse(body2)
}
if err2 != nil {
return nil, err2
}
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", status2, ctype2, previewResponseBody(body2))
}
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", statusCode, contentType, preview)
}
if !json.Valid(body) {
@@ -221,22 +208,6 @@ func toChatCompletionsContent(msg Message) []map[string]interface{} {
return content
}
func sanitizeResponsesRetryMessages(messages []Message) []Message {
out := make([]Message, 0, len(messages))
for _, m := range messages {
if strings.EqualFold(strings.TrimSpace(m.Role), "tool") {
text := strings.TrimSpace(m.Content)
if text == "" {
continue
}
out = append(out, Message{Role: "user", Content: "[tool_result_fallback] " + text})
continue
}
out = append(out, m)
}
return out
}
func toResponsesInputItems(msg Message) []map[string]interface{} {
return toResponsesInputItemsWithState(msg, nil)
}
@@ -299,12 +270,12 @@ func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{
case "tool":
callID := strings.TrimSpace(msg.ToolCallID)
if callID == "" {
return []map[string]interface{}{responsesMessageItem("user", msg.Content, "input_text")}
return nil
}
if pendingCalls != nil {
if _, ok := pendingCalls[callID]; !ok {
// Avoid invalid orphan/duplicate tool outputs in /responses payload.
return []map[string]interface{}{responsesMessageItem("user", msg.Content, "input_text")}
// Strict pairing: drop orphan/duplicate tool outputs instead of degrading role.
return nil
}
delete(pendingCalls, callID)
}

View File

@@ -0,0 +1,42 @@
package providers
import "testing"
func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) {
pending := map[string]struct{}{}
orphan := Message{Role: "tool", ToolCallID: "call-orphan", Content: "orphan output"}
if got := toResponsesInputItemsWithState(orphan, pending); len(got) != 0 {
t.Fatalf("expected orphan tool output to be dropped, got: %#v", got)
}
assistant := Message{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call-1",
Name: "read",
Arguments: map[string]interface{}{
"path": "README.md",
},
}},
}
items := toResponsesInputItemsWithState(assistant, pending)
if len(items) == 0 {
t.Fatalf("assistant tool call should produce responses items")
}
if _, ok := pending["call-1"]; !ok {
t.Fatalf("assistant tool call id should be tracked as pending")
}
matched := Message{Role: "tool", ToolCallID: "call-1", Content: "file content"}
matchedItems := toResponsesInputItemsWithState(matched, pending)
if len(matchedItems) != 1 {
t.Fatalf("expected matched tool output item, got %#v", matchedItems)
}
if matchedItems[0]["type"] != "function_call_output" {
t.Fatalf("expected function_call_output item, got %#v", matchedItems[0])
}
if _, ok := pending["call-1"]; ok {
t.Fatalf("matched tool output should clear pending call id")
}
}