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,