From 69d4677cb0bd48dec912ef2b46c4b890dcd5a224 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sun, 19 Jul 2026 16:46:49 +0530 Subject: [PATCH] Clone shared runOptions before appending per-run options prepareRun prepended the agent-configured run options with append(a.runOptions, options...). a.runOptions is shared across every run of the Agent and is built in New with slices.Clone followed by a per-tool append, so it routinely carries spare capacity. Appending onto it wrote each run's per-run options (the synthesized WithSession/noSessionProvided, or a caller's WithServiceID) into that shared backing array. Under concurrent runs of one Agent this is a data race, and one run can observe another run's clobbered session/service ID. Clone runOptions before appending so each run gets a private slice. --- agent/agent.go | 8 ++++++-- agent/agent_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index ac2efd6d..3a3f8e57 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -473,9 +473,13 @@ func (a *Agent) handleHistoryProviderConflict(ctx context.Context, provider Hist } func (a *Agent) prepareRun(ctx context.Context, messages []*message.Message, options []Option) (context.Context, []*message.Message, []Option, error) { - // Prepend options from agent configuration. + // Prepend options from agent configuration. Clone runOptions first: it is + // shared across every run of this Agent and, because New builds it with a + // per-tool append, it can carry spare capacity. Appending onto it directly + // would write this run's options into the shared backing array, racing with + // (and corrupting) concurrent runs. if len(a.runOptions) != 0 { - options = append(a.runOptions, options...) + options = append(slices.Clone(a.runOptions), options...) } if _, ok := GetOption(options, WithSession); !ok { diff --git a/agent/agent_test.go b/agent/agent_test.go index 98025060..f5d1cc3f 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -7,6 +7,8 @@ import ( "errors" "iter" "slices" + "strconv" + "sync" "testing" "github.com/microsoft/agent-framework-go/agent" @@ -107,6 +109,44 @@ func updateStrings(updates []*agent.ResponseUpdate) []string { return strings } +// TestAgent_ConcurrentRunsDoNotShareRunOptions guards against prepareRun +// mutating the Agent's shared runOptions slice. New builds runOptions with +// slices.Clone followed by a per-tool append, which leaves spare capacity, so a +// naive append(a.runOptions, options...) writes each run's per-run options into +// the shared backing array — a data race (and cross-run option corruption) when +// the same Agent serves concurrent runs. Run with -race. +func TestAgent_ConcurrentRunsDoNotShareRunOptions(t *testing.T) { + run := func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{Contents: []message.Content{&message.TextContent{Text: "ok"}}}, nil) + } + } + a := agent.New(agent.ProviderConfig{Run: run}, agent.Config{ + ID: "test-agent", + Tools: []tool.Tool{ + stubTool{name: "t1"}, stubTool{name: "t2"}, stubTool{name: "t3"}, + }, + DisableFuncAutoCall: true, + }) + + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + session, err := a.CreateSession(context.Background(), agent.WithServiceID("svc-"+strconv.Itoa(i))) + if err != nil { + t.Errorf("CreateSession: %v", err) + return + } + if _, err := a.RunText(context.Background(), "hi", agent.WithSession(session)).Collect(); err != nil { + t.Errorf("run: %v", err) + } + }(i) + } + wg.Wait() +} + func newGenericTestAgent(runFn func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error], middlewares []agent.Middleware, runOptions ...agent.Option) *agent.Agent { return agent.New(agent.ProviderConfig{ Run: runFn,