From fa36dc2c60fbd7e42fd090a538bec4b609a1b84b Mon Sep 17 00:00:00 2001 From: weiwei Date: Fri, 7 Aug 2026 17:17:03 +0800 Subject: [PATCH] fix(anthropic): pass back thinking block for assistant turns without reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek 类 thinking 模型在 adaptive thinking 下部分 tool-call 轮次不输出 thinking 块,重放时适配器不生成 thinking 块,上游 Anthropic 兼容 API 在 thinking 模式下要求每个 assistant 轮次回传 thinking 块,导致 "The content[].thinking in the thinking mode must be passed back to the API." 400 且毒化会话历史,后续所有请求持续失败。 引入 thinking carrier:缺 reasoning 的轮次复用请求内最近一个有 reasoning+signature 的 assistant 轮次兜底,无 carrier 时输出空 thinking 块; thinking 关闭时行为不变。与 openai.go 已有空 reasoning_content 兜底对称。 Closes #268 --- internal/backend/agent/model/anthropic.go | 35 +++- .../model/anthropic_thinking_carrier_test.go | 164 ++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 internal/backend/agent/model/anthropic_thinking_carrier_test.go diff --git a/internal/backend/agent/model/anthropic.go b/internal/backend/agent/model/anthropic.go index a409b2b..accf67e 100644 --- a/internal/backend/agent/model/anthropic.go +++ b/internal/backend/agent/model/anthropic.go @@ -1134,10 +1134,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool { } } +// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。 +// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek +// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的 +// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。 +type anthropicThinkingCarrier struct { + reasoning string + signature string +} + func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) { systemParts := make([]string, 0, len(input)) messages := make([]anthropicMessage, 0, len(input)) pendingToolResults := make([]map[string]any, 0, 2) + var thinkingCarrier *anthropicThinkingCarrier flushToolResults := func() { if len(pendingToolResults) == 0 { return @@ -1175,7 +1185,15 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r }) case "user", "assistant": flushToolResults() - contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled) + if thinkingEnabled && role == "assistant" { + if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" { + thinkingCarrier = &anthropicThinkingCarrier{ + reasoning: reasoning, + signature: anthropicThinkingSignature(message), + } + } + } + contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier) if err != nil { return nil, nil, err } @@ -1284,7 +1302,7 @@ func isAnthropicImageBlock(block map[string]any) bool { return strings.TrimSpace(anthropicStringField(block, "type")) == "image" } -func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) { +func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) { blocks, err := anthropicContentBlocks(message) if err != nil { return nil, err @@ -1293,11 +1311,17 @@ func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]ma return blocks, nil } + reasoning := strings.TrimSpace(message.ReasoningContent) + signature := anthropicThinkingSignature(message) + if reasoning == "" && carrier != nil { + reasoning = carrier.reasoning + signature = carrier.signature + } thinkingBlock := map[string]any{ "type": "thinking", - "thinking": message.ReasoningContent, + "thinking": reasoning, } - if signature := anthropicThinkingSignature(message); signature != "" { + if signature != "" { thinkingBlock["signature"] = signature } return append([]map[string]any{thinkingBlock}, blocks...), nil @@ -1381,9 +1405,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool) if strings.TrimSpace(message.Role) != "assistant" { return false } - if strings.TrimSpace(message.ReasoningContent) == "" { - return false - } return true } diff --git a/internal/backend/agent/model/anthropic_thinking_carrier_test.go b/internal/backend/agent/model/anthropic_thinking_carrier_test.go new file mode 100644 index 0000000..f67aa98 --- /dev/null +++ b/internal/backend/agent/model/anthropic_thinking_carrier_test.go @@ -0,0 +1,164 @@ +package modeladapter + +import ( + "strings" + "testing" +) + +// TestNormalizeAnthropicProviderMessagesThinkingCarrier 验证 thinking 模式下, +// 缺少 reasoning 的 assistant 轮次(如 DeepSeek adaptive thinking 跳过思考的 +// tool-call 轮次)会用请求内最近一个 carrier 的 thinking+signature 兜底, +// 保证每个 assistant 轮次都有 thinking 块,避免上游 "thinking must be passed +// back to the API" 400。 +func TestNormalizeAnthropicProviderMessagesThinkingCarrier(t *testing.T) { + carrierToolCall := []ToolCallDescriptor{{ + ID: "call-2", + Type: "function", + Function: ToolCallFunctionShape{ + Name: "read", + Arguments: `{}`, + }, + }} + input := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"}, + {Role: "user", Content: "tool result 1"}, + {Role: "assistant", ToolCalls: carrierToolCall}, // 无 reasoning → 用 carrier + {Role: "user", Content: "tool result 2"}, + {Role: "assistant", Content: "done", ReasoningContent: "R2", ReasoningSignature: "S2"}, + } + + _, messages, err := normalizeAnthropicProviderMessages(input, true, false) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if len(messages) != 6 { + t.Fatalf("expected 6 messages, got %d", len(messages)) + } + + // 第 2 条(有 reasoning)应保留自己的 thinking。 + assertAnthropicThinkingBlock(t, messages[1], "R1", "S1") + // 第 4 条(无 reasoning 的 tool-call 轮次)应复用 carrier 的 thinking+signature。 + assertAnthropicThinkingBlock(t, messages[3], "R1", "S1") + // 第 5 条应为 tool_result 消息(合并路径不适用时,tool-call 轮次独立成消息)。 + if role := messages[4].Role; role != "user" { + t.Fatalf("expected messages[4] role=user, got %s", role) + } + // 第 6 条有自己的 thinking。 + assertAnthropicThinkingBlock(t, messages[5], "R2", "S2") + + // tool-call 轮次应包含 tool_use 块。 + hasToolUse := false + for _, block := range messages[3].Content { + if strings.TrimSpace(anthropicStringField(block, "type")) == "tool_use" { + hasToolUse = true + } + } + if !hasToolUse { + t.Fatal("expected tool_use block on the carrier-fallback assistant message") + } +} + +// TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn 验证请求内第一条 +// assistant 轮次就缺 reasoning 且无 carrier 时,兜底输出空 thinking 块。 +func TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn(t *testing.T) { + input := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "ok"}, + } + + _, messages, err := normalizeAnthropicProviderMessages(input, true, false) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + if got := anthropicStringField(messages[1].Content[0], "type"); got != "thinking" { + t.Fatalf("expected first block type=thinking, got %s", got) + } + if got := anthropicStringField(messages[1].Content[0], "thinking"); got != "" { + t.Fatalf("expected empty fallback thinking, got %q", got) + } +} + +// TestNormalizeAnthropicProviderMessagesThinkingDisabled 验证 thinking 关闭时 +// 不输出任何 thinking 块(回归保护)。 +func TestNormalizeAnthropicProviderMessagesThinkingDisabled(t *testing.T) { + input := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "ok", ReasoningContent: "R1", ReasoningSignature: "S1"}, + {Role: "assistant", ToolCalls: []ToolCallDescriptor{{ + ID: "call-2", + Type: "function", + Function: ToolCallFunctionShape{ + Name: "read", + Arguments: `{}`, + }, + }}}, + } + + _, messages, err := normalizeAnthropicProviderMessages(input, false, false) + if err != nil { + t.Fatalf("normalize: %v", err) + } + for index, message := range messages { + for _, block := range message.Content { + if blockType := anthropicStringField(block, "type"); blockType == "thinking" { + t.Fatalf("unexpected thinking block at messages[%d]", index) + } + } + } +} + +// TestNormalizeAnthropicProviderMessagesThinkingMerge 验证有 reasoning 的纯 +// tool-call 轮次仍按既有逻辑合并进上一条 assistant 消息(thinking 去重,无回归)。 +func TestNormalizeAnthropicProviderMessagesThinkingMerge(t *testing.T) { + input := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"}, + {Role: "assistant", ToolCalls: []ToolCallDescriptor{{ + ID: "call-2", + Type: "function", + Function: ToolCallFunctionShape{ + Name: "read", + Arguments: `{}`, + }, + }}, ReasoningContent: "R1", ReasoningSignature: "S1"}, + } + + _, messages, err := normalizeAnthropicProviderMessages(input, true, false) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if len(messages) != 2 { + t.Fatalf("expected 2 messages (tool-call merged), got %d", len(messages)) + } + assertAnthropicThinkingBlock(t, messages[1], "R1", "S1") + hasToolUse := false + for _, block := range messages[1].Content { + if blockType := anthropicStringField(block, "type"); blockType == "tool_use" { + hasToolUse = true + } + } + if !hasToolUse { + t.Fatal("expected merged tool_use block on messages[1]") + } +} + +func assertAnthropicThinkingBlock(t *testing.T, message anthropicMessage, wantThinking string, wantSignature string) { + t.Helper() + if len(message.Content) == 0 { + t.Fatalf("expected non-empty content for %s message", message.Role) + } + first := message.Content[0] + if blockType := anthropicStringField(first, "type"); blockType != "thinking" { + t.Fatalf("expected first block type=thinking, got %s", blockType) + } + if got := anthropicStringField(first, "thinking"); got != wantThinking { + t.Fatalf("expected thinking=%q, got %q", wantThinking, got) + } + if got := anthropicStringField(first, "signature"); got != wantSignature { + t.Fatalf("expected signature=%q, got %q", wantSignature, got) + } +}