From 7df7e0495cd6225f51ac50c11fd74cb5a3bc5e1e Mon Sep 17 00:00:00 2001 From: Yajun Yao Date: Fri, 6 Mar 2026 16:04:31 +0800 Subject: [PATCH] fix deepseek-chat bug (#1066) Co-authored-by: FantasticCode2019 <1443996278@qq.com> --- pkg/agent/context.go | 55 ++++++++++++++++++++++++++++- pkg/agent/context_test.go | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d84aea627..719b0cb6d 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -605,7 +605,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } } - return sanitized + // Second pass: ensure every assistant message with tool_calls has matching + // tool result messages following it. This is required by strict providers + // like DeepSeek that enforce: "An assistant message with 'tool_calls' must + // be followed by tool messages responding to each 'tool_call_id'." + final := make([]providers.Message, 0, len(sanitized)) + for i := 0; i < len(sanitized); i++ { + msg := sanitized[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + // Collect expected tool_call IDs + expected := make(map[string]bool, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + expected[tc.ID] = false + } + + // Check following messages for matching tool results + toolMsgCount := 0 + for j := i + 1; j < len(sanitized); j++ { + if sanitized[j].Role != "tool" { + break + } + toolMsgCount++ + if _, exists := expected[sanitized[j].ToolCallID]; exists { + expected[sanitized[j].ToolCallID] = true + } + } + + // If any tool_call_id is missing, drop this assistant message and its partial tool messages + allFound := true + for toolCallID, found := range expected { + if !found { + allFound = false + logger.DebugCF( + "agent", + "Dropping assistant message with incomplete tool results", + map[string]any{ + "missing_tool_call_id": toolCallID, + "expected_count": len(expected), + "found_count": toolMsgCount, + }, + ) + break + } + } + + if !allFound { + // Skip this assistant message and its tool messages + i += toolMsgCount + continue + } + } + final = append(final, msg) + } + + return final } func (cb *ContextBuilder) AddToolResult( diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index e023c9c30..5756ed911 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) { } } } + +// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation +// that ensures assistant messages with tool_calls have ALL matching tool results. +// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be +// followed by tool messages responding to each 'tool_call_id'." +func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) { + // Assistant expects tool results for both A and B, but only A is present + history := []providers.Message{ + msg("user", "do two things"), + assistantWithTools("A", "B"), + toolResult("A"), + // toolResult("B") is missing - this would cause DeepSeek to fail + msg("user", "next question"), + msg("assistant", "answer"), + } + + result := sanitizeHistoryForProvider(history) + // The assistant message with incomplete tool results should be dropped, + // along with its partial tool result. The remaining messages are: + // user ("do two things"), user ("next question"), assistant ("answer") + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "user", "assistant") +} + +// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where +// an assistant message has tool_calls but no tool results follow at all. +func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "do something"), + assistantWithTools("A"), + // No tool results at all + msg("user", "hello"), + msg("assistant", "hi"), + } + + result := sanitizeHistoryForProvider(history) + // The assistant message with no tool results should be dropped. + // Remaining: user ("do something"), user ("hello"), assistant ("hi") + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "user", "assistant") +} + +// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that +// incomplete tool results in the middle of a conversation are properly handled. +func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) { + history := []providers.Message{ + msg("user", "first"), + assistantWithTools("A"), + toolResult("A"), + msg("assistant", "done"), + msg("user", "second"), + assistantWithTools("B", "C"), + toolResult("B"), + // toolResult("C") is missing + msg("user", "third"), + assistantWithTools("D"), + toolResult("D"), + msg("assistant", "all done"), + } + + result := sanitizeHistoryForProvider(history) + // First round is complete (user, assistant+tools, tool, assistant), + // second round is incomplete and dropped (assistant+tools, partial tool), + // third round is complete (user, assistant+tools, tool, assistant). + // Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant + if len(result) != 9 { + t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant") +}