diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 2f05b2f12..dce975b47 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -286,7 +286,7 @@ func (a languageModel) prepareParams(call fantasy.Call) ( if providerOptions.SendReasoning != nil { sendReasoning = *providerOptions.SendReasoning } - systemBlocks, messages, warnings := toPrompt(call.Prompt, sendReasoning) + systemBlocks, messages, warnings := toPrompt(call.Prompt, sendReasoning, GetMessageCache(call.ProviderOptions)) if call.FrequencyPenalty != nil { warnings = append(warnings, fantasy.CallWarning{ @@ -760,7 +760,7 @@ func (a languageModel) toTools(tools []fantasy.Tool, toolChoice *fantasy.ToolCho return rawTools, anthropicToolChoice, warnings, betaFlags } -func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBlockParam, []anthropic.MessageParam, []fantasy.CallWarning) { +func toPrompt(prompt fantasy.Prompt, sendReasoningData bool, cache MessageSerializationCache) ([]anthropic.TextBlockParam, []anthropic.MessageParam, []fantasy.CallWarning) { var systemBlocks []anthropic.TextBlockParam var messages []anthropic.MessageParam var warnings []fantasy.CallWarning @@ -799,6 +799,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl case fantasy.MessageRoleUser: var anthropicContent []anthropic.ContentBlockParamUnion + blockHasCacheControl := false for _, msg := range block.Messages { if msg.Role == fantasy.MessageRoleUser { for i, part := range msg.Content { @@ -807,6 +808,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } + if cacheControl != nil { + blockHasCacheControl = true + } switch part.GetType() { case fantasy.ContentTypeText: text, ok := fantasy.AsMessagePart[fantasy.TextPart](part) @@ -855,6 +859,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } + if cacheControl != nil { + blockHasCacheControl = true + } result, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) if !ok { continue @@ -923,9 +930,12 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl }) continue } - messages = append(messages, anthropic.NewUserMessage(anthropicContent...)) + outIdx := len(messages) + msgParam := anthropic.NewUserMessage(anthropicContent...) + messages = append(messages, applySerialisationCache(cache, outIdx, blockHasCacheControl, msgParam)) case fantasy.MessageRoleAssistant: var anthropicContent []anthropic.ContentBlockParamUnion + blockHasCacheControl := false for _, msg := range block.Messages { for i, part := range msg.Content { isLastPart := i == len(msg.Content)-1 @@ -933,6 +943,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl if cacheControl == nil && isLastPart { cacheControl = GetCacheControl(msg.ProviderOptions) } + if cacheControl != nil { + blockHasCacheControl = true + } switch part.GetType() { case fantasy.ContentTypeText: text, ok := fantasy.AsMessagePart[fantasy.TextPart](part) @@ -1042,12 +1055,45 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl }) continue } - messages = append(messages, anthropic.NewAssistantMessage(anthropicContent...)) + outIdx := len(messages) + msgParam := anthropic.NewAssistantMessage(anthropicContent...) + messages = append(messages, applySerialisationCache(cache, outIdx, blockHasCacheControl, msgParam)) } } return systemBlocks, messages, warnings } +// applySerialisationCache applies cache-based serialisation to a +// MessageParam. When the cache is nil or the block has cache +// control directives (which change between calls), the message is +// returned unmodified. On a cache hit the pre-serialised JSON is +// applied via param.SetJSON. On a miss the message is serialised, +// stored, and then param.SetJSON is applied so the SDK skips +// re-serialisation. +func applySerialisationCache( + cache MessageSerializationCache, + idx int, + hasCacheControl bool, + msg anthropic.MessageParam, +) anthropic.MessageParam { + if cache == nil || hasCacheControl { + return msg + } + if cached, ok := cache.Get(idx); ok { + param.SetJSON(cached, &msg) + return msg + } + data, err := msg.MarshalJSON() + if err != nil { + // Serialisation should never fail for well-formed params. + // Fall through without caching. + return msg + } + cache.Set(idx, data) + param.SetJSON(data, &msg) + return msg +} + func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool { for _, block := range content { if block.OfText != nil || block.OfImage != nil || block.OfToolResult != nil { diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 4387a34fa..ae4777733 100644 --- a/providers/anthropic/anthropic_test.go +++ b/providers/anthropic/anthropic_test.go @@ -51,7 +51,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1, "should only have user message, assistant message should be dropped") @@ -86,7 +86,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, false) + systemBlocks, messages, warnings := toPrompt(prompt, false, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1, "should only have user message, assistant message should be dropped") @@ -113,7 +113,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1, "should only have user message") @@ -140,7 +140,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 2, "should have both user and assistant messages") @@ -169,7 +169,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 2, "should have both user and assistant messages") @@ -198,7 +198,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1, "should only have user message") @@ -233,7 +233,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 2, "should have both user and assistant messages") @@ -255,7 +255,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1) @@ -277,7 +277,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Empty(t, messages) @@ -302,7 +302,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1) @@ -324,7 +324,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1) @@ -349,7 +349,7 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) { }, } - systemBlocks, messages, warnings := toPrompt(prompt, true) + systemBlocks, messages, warnings := toPrompt(prompt, true, nil) require.Empty(t, systemBlocks) require.Len(t, messages, 1) @@ -741,7 +741,7 @@ func TestToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) { }, } - _, messages, warnings := toPrompt(prompt, true) + _, messages, warnings := toPrompt(prompt, true, nil) // No warnings expected; the provider-executed result is in the // assistant message so there is no empty tool message to drop. diff --git a/providers/anthropic/provider_options.go b/providers/anthropic/provider_options.go index 1bff4979a..64338ec73 100644 --- a/providers/anthropic/provider_options.go +++ b/providers/anthropic/provider_options.go @@ -188,6 +188,34 @@ type CacheControl struct { Type string `json:"type"` } +// MessageCacheKey is the provider-options key used to pass a +// MessageSerializationCache through fantasy.Call. +const MessageCacheKey = Name + ".message_cache" + +// MessageSerializationCache allows pre-serialized message JSON to +// be reused across agentic loop steps. The cache is keyed by output +// MessageParam index. Implementations need not be safe for +// concurrent use. +type MessageSerializationCache interface { + Get(index int) (json.RawMessage, bool) + Set(index int, data json.RawMessage) + Clear() +} + +// GetMessageCache extracts a MessageSerializationCache from +// provider options, if present. +func GetMessageCache(providerOptions fantasy.ProviderOptions) MessageSerializationCache { + v, ok := providerOptions[MessageCacheKey] + if !ok { + return nil + } + cache, ok := v.(MessageSerializationCache) + if !ok { + return nil + } + return cache +} + // NewProviderOptions creates new provider options for the Anthropic provider. func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions { return fantasy.ProviderOptions{