mirror of
https://github.com/vxcontrol/langchaingo.git
synced 2026-08-25 11:10:02 -04:00
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:
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user