diff --git a/schema/agentic_message.go b/schema/agentic_message.go index 2474e5df8..8c2fd821e 100644 --- a/schema/agentic_message.go +++ b/schema/agentic_message.go @@ -289,6 +289,9 @@ type Reasoning struct { // OpenAIExtension is the extension for OpenAI. OpenAIExtension *openai.ReasoningExtension `json:"openai_extension,omitempty"` + + // ClaudeExtension is the extension for Claude. + ClaudeExtension *claude.ReasoningExtension `json:"claude_extension,omitempty"` } type FunctionToolCall struct { @@ -1315,6 +1318,7 @@ func concatReasoning(reasons []*Reasoning) (ret *Reasoning, err error) { ret = &Reasoning{} openaiExtensions := make([]*openai.ReasoningExtension, 0, len(reasons)) + claudeExtensions := make([]*claude.ReasoningExtension, 0, len(reasons)) for _, r := range reasons { if r == nil { @@ -1329,6 +1333,9 @@ func concatReasoning(reasons []*Reasoning) (ret *Reasoning, err error) { if r.OpenAIExtension != nil { openaiExtensions = append(openaiExtensions, r.OpenAIExtension) } + if r.ClaudeExtension != nil { + claudeExtensions = append(claudeExtensions, r.ClaudeExtension) + } } if len(openaiExtensions) > 0 { @@ -1337,6 +1344,12 @@ func concatReasoning(reasons []*Reasoning) (ret *Reasoning, err error) { return nil, fmt.Errorf("failed to concat openai reasoning extensions: %w", err) } } + if len(claudeExtensions) > 0 { + ret.ClaudeExtension, err = claude.ConcatReasoningExtensions(claudeExtensions) + if err != nil { + return nil, fmt.Errorf("failed to concat claude reasoning extensions: %w", err) + } + } return ret, nil } diff --git a/schema/agentic_message_test.go b/schema/agentic_message_test.go index 32dc96c2e..c5d73e92e 100644 --- a/schema/agentic_message_test.go +++ b/schema/agentic_message_test.go @@ -18,13 +18,35 @@ package schema import ( "context" + "encoding/json" "reflect" "testing" + "github.com/cloudwego/eino/schema/claude" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestClaudeRedactedThinkingJSONRoundTrip(t *testing.T) { + want := &AgenticMessage{ + Role: AgenticRoleTypeAssistant, + ContentBlocks: []*ContentBlock{ + NewContentBlock(&Reasoning{ + ClaudeExtension: &claude.ReasoningExtension{ + RedactedThinking: &claude.RedactedThinking{Data: "opaque-data"}, + }, + }), + }, + } + + data, err := json.Marshal(want) + require.NoError(t, err) + + got := &AgenticMessage{} + require.NoError(t, json.Unmarshal(data, got)) + assert.Equal(t, want, got) +} + func TestConcatAgenticMessages(t *testing.T) { t.Run("single message", func(t *testing.T) { msg := &AgenticMessage{ @@ -170,6 +192,40 @@ func TestConcatAgenticMessages(t *testing.T) { assert.Equal(t, "Part1-Part3", result.ContentBlocks[0].Reasoning.Text) }) + t.Run("concat claude redacted thinking extension", func(t *testing.T) { + msgs := []*AgenticMessage{ + { + Role: AgenticRoleTypeAssistant, + ContentBlocks: []*ContentBlock{ + { + Type: ContentBlockTypeReasoning, + Reasoning: &Reasoning{ClaudeExtension: &claude.ReasoningExtension{ + RedactedThinking: &claude.RedactedThinking{Data: "opaque-data"}, + }}, + StreamingMeta: &StreamingMeta{Index: 0}, + }, + }, + }, + { + Role: AgenticRoleTypeAssistant, + ContentBlocks: []*ContentBlock{ + { + Type: ContentBlockTypeReasoning, + Reasoning: &Reasoning{}, + StreamingMeta: &StreamingMeta{Index: 0}, + }, + }, + }, + } + + result, err := ConcatAgenticMessages(msgs) + require.NoError(t, err) + require.Len(t, result.ContentBlocks, 1) + require.NotNil(t, result.ContentBlocks[0].Reasoning.ClaudeExtension) + require.NotNil(t, result.ContentBlocks[0].Reasoning.ClaudeExtension.RedactedThinking) + assert.Equal(t, "opaque-data", result.ContentBlocks[0].Reasoning.ClaudeExtension.RedactedThinking.Data) + }) + t.Run("concat user input text", func(t *testing.T) { msgs := []*AgenticMessage{ { diff --git a/schema/claude/extension.go b/schema/claude/extension.go index 239941bbc..d1d2fd632 100644 --- a/schema/claude/extension.go +++ b/schema/claude/extension.go @@ -36,6 +36,18 @@ type AssistantGenTextExtension struct { Citations []*TextCitation `json:"citations,omitempty"` } +// ReasoningExtension contains Claude-specific reasoning data. +type ReasoningExtension struct { + // RedactedThinking contains an opaque encrypted thinking block returned by Claude. + RedactedThinking *RedactedThinking `json:"redacted_thinking,omitempty"` +} + +// RedactedThinking is an opaque encrypted thinking block that must be passed back +// to Claude unchanged when continuing the conversation. +type RedactedThinking struct { + Data string `json:"data"` +} + type TextCitation struct { Type TextCitationType `json:"type,omitempty"` @@ -104,6 +116,25 @@ func ConcatAssistantGenTextExtensions(chunks []*AssistantGenTextExtension) (*Ass return ret, nil } +// ConcatReasoningExtensions merges multiple ReasoningExtension chunks into one. +func ConcatReasoningExtensions(chunks []*ReasoningExtension) (*ReasoningExtension, error) { + if len(chunks) == 0 { + return nil, fmt.Errorf("no reasoning extension found") + } + if len(chunks) == 1 { + return chunks[0], nil + } + + ret := &ReasoningExtension{} + for _, ext := range chunks { + if ext != nil && ext.RedactedThinking != nil { + ret.RedactedThinking = ext.RedactedThinking + } + } + + return ret, nil +} + // ConcatResponseMetaExtensions merges multiple ResponseMetaExtension chunks into one. func ConcatResponseMetaExtensions(chunks []*ResponseMetaExtension) (*ResponseMetaExtension, error) { if len(chunks) == 0 { diff --git a/schema/claude/extension_test.go b/schema/claude/extension_test.go index 474fe740b..1df255a47 100644 --- a/schema/claude/extension_test.go +++ b/schema/claude/extension_test.go @@ -140,6 +140,18 @@ func TestConcatAssistantGenTextExtensions(t *testing.T) { }) } +func TestConcatReasoningExtensions(t *testing.T) { + redacted := &RedactedThinking{Data: "opaque-data"} + + result, err := ConcatReasoningExtensions([]*ReasoningExtension{ + {}, + {RedactedThinking: redacted}, + {}, + }) + assert.NoError(t, err) + assert.Same(t, redacted, result.RedactedThinking) +} + func TestConcatResponseMetaExtensions(t *testing.T) { t.Run("multiple extensions - takes last non-empty values", func(t *testing.T) { exts := []*ResponseMetaExtension{