From c68a0507d58b8686ffe71128d91853e28b7e27ce Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 18 Jul 2026 00:26:40 +0530 Subject: [PATCH 1/2] Clone anthropic MessageNewParams slices to avoid mutating caller state buildMessageParams took the caller-provided MessageNewParams option by struct copy (params = p) and then appended system instructions and messages to params.System / params.Messages. Because the struct copy shares the caller's slice backing arrays, appending within spare capacity silently overwrote the caller's data, and a reused params value would accumulate duplicated system blocks across runs. Clone the two mutated slice fields after adopting the option, mirroring what the gemini provider already does for the same reason. Adds a black-box test asserting the caller's System backing array is untouched. --- provider/anthropicprovider/agent.go | 5 ++++ provider/anthropicprovider/agent_test.go | 30 ++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 4325cf5c..d128a9e7 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -288,6 +288,11 @@ func (a *client) buildMessageParams(messages []*message.Message, opts []agent.Op var params anthropic.MessageNewParams if p, ok := agent.GetOption(opts, MessageNewParams); ok { params = p + // Clone the mutable slice fields appended to below so we never mutate + // the caller's backing arrays (the option stores a shallow copy of the + // struct); the gemini provider clones for the same reason. + params.System = slices.Clone(params.System) + params.Messages = slices.Clone(params.Messages) } params.Model = cmp.Or(params.Model, a.config.Model) params.MaxTokens = cmp.Or(params.MaxTokens, 4096) diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 3e0adfb4..201d0012 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -34,7 +34,8 @@ func newTestClient(t *testing.T, server *httptest.Server) *agent.Agent { anthropicprovider.AgentConfig{ Model: "claude-3-5-sonnet-20241022", Config: agent.Config{DisableFuncAutoCall: true}, - }) + }, + ) } // nestedKey traverses a decoded JSON map following the given path of keys and @@ -197,7 +198,8 @@ func TestConfigInstructions(t *testing.T) { Config: agent.Config{ DisableFuncAutoCall: true, }, - }) + }, + ) if _, err := a.RunText(t.Context(), "hi").Collect(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -455,3 +457,27 @@ func TestStreamingToolCallsSupportInterleavedDeltas(t *testing.T) { } } } + +// Building the request must not mutate the caller's MessageNewParams slices. +// The provider appends system instructions to params.System; if it shares the +// caller's backing array (spare capacity), the append corrupts the caller's data. +func TestBuildMessageParams_DoesNotMutateCallerSystemSlice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"m","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`) + })) + defer server.Close() + a := newTestClient(t, server) + + // Caller-supplied System slice with spare capacity. + system := make([]anthropic.TextBlockParam, 1, 4) + system[0] = anthropic.TextBlockParam{Text: "s0"} + opt := anthropicprovider.MessageNewParams(anthropic.MessageNewParams{System: system}) + + if _, err := a.RunText(t.Context(), "hi", agent.WithInstructions("added"), opt).Collect(); err != nil { + t.Fatalf("run: %v", err) + } + if full := system[:cap(system)]; full[1].Text != "" { + t.Errorf("provider mutated the caller's System backing array: spare slot = %q", full[1].Text) + } +} From a25cfb1cd1a1b081f00463ba4181801a8a872cd7 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 18 Jul 2026 09:52:41 +0530 Subject: [PATCH 2/2] Also cover the Messages slice in the params-aliasing test Address review feedback: the fix clones both System and Messages, but the test only covered System. Add a regression test asserting a caller-supplied Messages slice with spare capacity is not mutated. --- provider/anthropicprovider/agent_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 201d0012..d2788a43 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -481,3 +481,26 @@ func TestBuildMessageParams_DoesNotMutateCallerSystemSlice(t *testing.T) { t.Errorf("provider mutated the caller's System backing array: spare slot = %q", full[1].Text) } } + +func TestBuildMessageParams_DoesNotMutateCallerMessagesSlice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"m","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`) + })) + defer server.Close() + a := newTestClient(t, server) + + // Caller-supplied Messages slice with spare capacity. The provider appends + // the run's messages to params.Messages; with aliasing that append lands in + // the caller's spare slot instead of a cloned slice. + messages := make([]anthropic.MessageParam, 1, 4) + messages[0] = anthropic.NewUserMessage(anthropic.NewTextBlock("seeded")) + opt := anthropicprovider.MessageNewParams(anthropic.MessageNewParams{Messages: messages}) + + if _, err := a.RunText(t.Context(), "hi", opt).Collect(); err != nil { + t.Fatalf("run: %v", err) + } + if full := messages[:cap(messages)]; len(full[1].Content) != 0 { + t.Errorf("provider mutated the caller's Messages backing array: spare slot has %d content block(s)", len(full[1].Content)) + } +}