Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"errors"
"iter"
"slices"
"strconv"
"sync"
"testing"

"github.com/microsoft/agent-framework-go/agent"
Expand Down Expand Up @@ -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,
Expand Down
Loading