fix(anthropic): never replay a thinking block without its thinking text

Replaying an assistant turn whose reasoning carried a signature but no
text produced {"type":"thinking","signature":"..."}: the thinking field
is tagged omitempty, so it vanished from the body and the API rejected
the request with messages.N.content.0.thinking.thinking: Field required,
killing the agent chain. Three places agreed that a bare signature is
reasoning — the response path built such a value, IsEmpty blessed it and
the request path emitted it.

Emit a block only when there is thinking text, keep scanning the
remaining parts instead of stopping at the first reasoning-bearing one,
stop building a reasoning value out of a lone signature, and drop
omitempty so the required field can no longer disappear silently.

PAGI-125

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-08-19 13:52:37 +07:00
parent e492c41de8
commit 31d03d3840
3 changed files with 112 additions and 12 deletions
+102
View File
@@ -15,6 +15,7 @@ import (
"github.com/vxcontrol/langchaingo/internal/httprr"
"github.com/vxcontrol/langchaingo/llms"
"github.com/vxcontrol/langchaingo/llms/anthropic"
"github.com/vxcontrol/langchaingo/llms/reasoning"
"github.com/vxcontrol/langchaingo/llms/streaming"
)
@@ -1000,3 +1001,104 @@ func TestAnthropic_InterleavedThinkingBetaHeader(t *testing.T) {
assert.NotContains(t, header.Get("anthropic-beta"), "interleaved-thinking")
})
}
// captureAssistantReplay returns the content blocks the adapter built for the
// assistant turn of the replayed history.
func captureAssistantReplay(t *testing.T, parts []llms.ContentPart) []any {
t.Helper()
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"claude-sonnet-5",` +
`"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))
}))
t.Cleanup(srv.Close)
llm, err := anthropic.New(
anthropic.WithToken("test-key"),
anthropic.WithBaseURL(srv.URL),
anthropic.WithModel("claude-sonnet-5"),
)
require.NoError(t, err)
messages := []llms.MessageContent{
{Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{llms.TextPart("hi")}},
{Role: llms.ChatMessageTypeAI, Parts: parts},
{Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{llms.TextPart("continue")}},
}
_, err = llm.GenerateContent(t.Context(), messages)
require.NoError(t, err)
var payload struct {
Messages []struct {
Content []any `json:"content"`
} `json:"messages"`
}
require.NoError(t, json.Unmarshal(body, &payload))
require.Len(t, payload.Messages, 3)
return payload.Messages[1].Content
}
func thinkingBlocks(blocks []any) []map[string]any {
var out []map[string]any
for _, b := range blocks {
if m, ok := b.(map[string]any); ok && m["type"] == "thinking" {
out = append(out, m)
}
}
return out
}
func TestAnthropic_ThinkingReplayNeverOmitsRequiredField(t *testing.T) {
t.Parallel()
t.Run("signature without text emits no thinking block", func(t *testing.T) {
t.Parallel()
blocks := captureAssistantReplay(t, []llms.ContentPart{
llms.TextContent{
Text: "answer",
Reasoning: &reasoning.ContentReasoning{Signature: []byte("sig-only")},
},
})
require.Empty(t, thinkingBlocks(blocks),
"a thinking block without the required thinking field must not be sent")
})
t.Run("text-bearing part is used when an earlier part carries only a signature", func(t *testing.T) {
t.Parallel()
blocks := captureAssistantReplay(t, []llms.ContentPart{
llms.TextContent{
Reasoning: &reasoning.ContentReasoning{Signature: []byte("sig-only")},
},
llms.TextContent{
Text: "answer",
Reasoning: &reasoning.ContentReasoning{Content: "real thinking", Signature: []byte("sig")},
},
})
emitted := thinkingBlocks(blocks)
require.Len(t, emitted, 1)
require.Equal(t, "real thinking", emitted[0]["thinking"])
})
t.Run("thinking text always reaches the wire", func(t *testing.T) {
t.Parallel()
blocks := captureAssistantReplay(t, []llms.ContentPart{
llms.TextContent{
Text: "answer",
Reasoning: &reasoning.ContentReasoning{Content: "step one", Signature: []byte("sig")},
},
})
emitted := thinkingBlocks(blocks)
require.Len(t, emitted, 1)
require.Equal(t, "step one", emitted[0]["thinking"])
require.Equal(t, "sig", emitted[0]["signature"])
})
}
+9 -11
View File
@@ -367,7 +367,7 @@ func processAnthropicResponse(result *anthropicclient.MessageResponsePayload) (*
// Create reasoning object
var contentReasoning *reasoning.ContentReasoning
if reasoningContent.Len() > 0 || len(signature) > 0 {
if reasoningContent.Len() > 0 {
contentReasoning = &reasoning.ContentReasoning{
Content: reasoningContent.String(),
Signature: signature,
@@ -807,19 +807,17 @@ func handleAIMessage(msg llms.MessageContent) (anthropicclient.ChatMessage, erro
// reasoning-bearing part among msg.Parts.
for _, part := range msg.Parts {
p, ok := part.(llms.TextContent)
if !ok || p.Reasoning == nil {
if !ok || p.Reasoning == nil || len(p.Reasoning.Content) == 0 {
continue
}
if len(p.Reasoning.Content) > 0 || len(p.Reasoning.Signature) > 0 {
thinkingBlock := &anthropicclient.ThinkingContent{
Type: "thinking",
Thinking: p.Reasoning.Content,
}
if len(p.Reasoning.Signature) > 0 {
thinkingBlock.Signature = string(p.Reasoning.Signature)
}
message.Content = append(message.Content, thinkingBlock)
thinkingBlock := &anthropicclient.ThinkingContent{
Type: "thinking",
Thinking: p.Reasoning.Content,
}
if len(p.Reasoning.Signature) > 0 {
thinkingBlock.Signature = string(p.Reasoning.Signature)
}
message.Content = append(message.Content, thinkingBlock)
break // one thinking block per assistant turn
}
@@ -161,7 +161,7 @@ type ImageSource struct {
type ThinkingContent struct {
Type string `json:"type"`
Thinking string `json:"thinking,omitempty"`
Thinking string `json:"thinking"`
Signature string `json:"signature,omitempty"`
}