@@ -87,12 +87,13 @@ type Model struct {
8787}
8888
8989type sessionAgent struct {
90− largeModel Model
91− smallModel Model
92− systemPromptPrefix string
93− systemPrompt string
90+ largeModel *csync.Value[Model]
91+ smallModel *csync.Value[Model]
92+ systemPromptPrefix *csync.Value[string]
93+ systemPrompt *csync.Value[string]
94+ tools *csync.Slice[fantasy.AgentTool]
95+
9496 isSubAgent bool
95− tools []fantasy.AgentTool
9697 sessions session.Service
9798 messages message.Service
9899 disableAutoSummarize bool
@@ -119,15 +120,15 @@ func NewSessionAgent(
119120 opts SessionAgentOptions,
120121) SessionAgent {
121122 return &sessionAgent{
122− largeModel: opts.LargeModel,
123− smallModel: opts.SmallModel,
124− systemPromptPrefix: opts.SystemPromptPrefix,
125− systemPrompt: opts.SystemPrompt,
123+ largeModel: csync.NewValue(opts.LargeModel),
124+ smallModel: csync.NewValue(opts.SmallModel),
125+ systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
126+ systemPrompt: csync.NewValue(opts.SystemPrompt),
126127 isSubAgent: opts.IsSubAgent,
127128 sessions: opts.Sessions,
128129 messages: opts.Messages,
129130 disableAutoSummarize: opts.DisableAutoSummarize,
130− tools: opts.Tools,
131+ tools: csync.NewSliceFrom(opts.Tools),
131132 isYolo: opts.IsYolo,
132133 messageQueue: csync.NewMap[string, []SessionAgentCall](),
133134 activeRequests: csync.NewMap[string, context.CancelFunc](),
@@ -153,15 +154,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
153154 return nil, nil
154155 }
155156
156− if len(a.tools) > 0 {
157+ // Copy mutable fields under lock to avoid races with SetTools/SetModels.
158+ agentTools := a.tools.Copy()
159+ largeModel := a.largeModel.Get()
160+ systemPrompt := a.systemPrompt.Get()
161+ promptPrefix := a.systemPromptPrefix.Get()
162+
163+ if len(agentTools) > 0 {
157164 // Add Anthropic caching to the last tool.
158− a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
165+ agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
159166 }
160167
161168 agent := fantasy.NewAgent(
162− a.largeModel.Model,
163− fantasy.WithSystemPrompt(a.systemPrompt),
164− fantasy.WithTools(a.tools...),
169+ largeModel.Model,
170+ fantasy.WithSystemPrompt(systemPrompt),
171+ fantasy.WithTools(agentTools...),
165172 )
166173
167174 sessionLock := sync.Mutex{}
@@ -234,7 +241,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
234241 prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
235242 }
236243
237− prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages)
244+ prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
238245
239246 lastSystemRoleInx := 0
240247 systemMessageUpdated := false
@@ -252,7 +259,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
252259 }
253260 }
254261
255− if promptPrefix := a.promptPrefix(); promptPrefix != "" {
262+ if promptPrefix != "" {
256263 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
257264 }
258265
@@ -260,15 +267,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
260267 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
261268 Role: message.Assistant,
262269 Parts: []message.ContentPart{},
263− Model: a.largeModel.ModelCfg.Model,
264− Provider: a.largeModel.ModelCfg.Provider,
270+ Model: largeModel.ModelCfg.Model,
271+ Provider: largeModel.ModelCfg.Provider,
265272 })
266273 if err != nil {
267274 return callContext, prepared, err
268275 }
269276 callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
270− callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages)
271− callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name)
277+ callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
278+ callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
272279 currentAssistant = &assistantMsg
273280 return callContext, prepared, err
274281 },
@@ -362,7 +369,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
362369 sessionLock.Unlock()
363370 return getSessionErr
364371 }
365− a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
372+ a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
366373 _, sessionErr := a.sessions.Save(genCtx, updatedSession)
367374 sessionLock.Unlock()
368375 if sessionErr != nil {
@@ -372,7 +379,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
372379 },
373380 StopWhen: []fantasy.StopCondition{
374381 func(_ []fantasy.StepResult) bool {
375− cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
382+ cw := int64(largeModel.CatwalkCfg.ContextWindow)
376383 tokens := currentSession.CompletionTokens + currentSession.PromptTokens
377384 remaining := cw - tokens
378385 var threshold int64
@@ -474,7 +481,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
474481 currentAssistant.AddFinish(
475482 message.FinishReasonError,
476483 "Copilot model not enabled",
477− fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", a.largeModel.CatwalkCfg.Name, link),
484+ fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
478485 )
479486 } else {
480487 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
@@ -529,6 +536,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
529536 return ErrSessionBusy
530537 }
531538
539+ // Copy mutable fields under lock to avoid races with SetModels.
540+ largeModel := a.largeModel.Get()
541+ systemPromptPrefix := a.systemPromptPrefix.Get()
542+
532543 currentSession, err := a.sessions.Get(ctx, sessionID)
533544 if err != nil {
534545 return fmt.Errorf("failed to get session: %w", err)
@@ -549,13 +560,13 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
549560 defer a.activeRequests.Del(sessionID)
550561 defer cancel()
551562
552− agent := fantasy.NewAgent(a.largeModel.Model,
563+ agent := fantasy.NewAgent(largeModel.Model,
553564 fantasy.WithSystemPrompt(string(summaryPrompt)),
554565 )
555566 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
556567 Role: message.Assistant,
557− Model: a.largeModel.Model.Model(),
558− Provider: a.largeModel.Model.Provider(),
568+ Model: largeModel.Model.Model(),
569+ Provider: largeModel.Model.Provider(),
559570 IsSummaryMessage: true,
560571 })
561572 if err != nil {
@@ -570,8 +581,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
570581 ProviderOptions: opts,
571582 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
572583 prepared.Messages = options.Messages
573− if a.systemPromptPrefix != "" {
574− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
584+ if systemPromptPrefix != "" {
585+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
575586 }
576587 return callContext, prepared, nil
577588 },
@@ -622,7 +633,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
622633 }
623634 }
624635
625− a.updateSessionUsage(a.largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
636+ a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
626637
627638 // Just in case, get just the last usage info.
628639 usage := resp.Response.Usage
@@ -730,9 +741,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
730741 return
731742 }
732743
744+ smallModel := a.smallModel.Get()
745+ largeModel := a.largeModel.Get()
746+ systemPromptPrefix := a.systemPromptPrefix.Get()
747+
733748 var maxOutputTokens int64 = 40
734− if a.smallModel.CatwalkCfg.CanReason {
735− maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens
749+ if smallModel.CatwalkCfg.CanReason {
750+ maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
736751 }
737752
738753 newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
@@ -746,9 +761,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
746761 Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
747762 PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
748763 prepared.Messages = opts.Messages
749− if a.systemPromptPrefix != "" {
764+ if systemPromptPrefix != "" {
750765 prepared.Messages = append([]fantasy.Message{
751− fantasy.NewSystemMessage(a.systemPromptPrefix),
766+ fantasy.NewSystemMessage(systemPromptPrefix),
752767 }, prepared.Messages...)
753768 }
754769 return callCtx, prepared, nil
@@ -756,7 +771,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
756771 }
757772
758773 // Use the small model to generate the title.
759− model := &a.smallModel
774+ model := smallModel
760775 agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
761776 resp, err := agent.Stream(ctx, streamCall)
762777 if err == nil {
@@ -765,7 +780,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
765780 } else {
766781 // It didn't work. Let's try with the big model.
767782 slog.Error("error generating title with small model; trying big model", "err", err)
768− model = &a.largeModel
783+ model = largeModel
769784 agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
770785 resp, err = agent.Stream(ctx, streamCall)
771786 if err == nil {
@@ -960,24 +975,20 @@ func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
960975}
961976
962977func (a *sessionAgent) SetModels(large Model, small Model) {
963− a.largeModel = large
964− a.smallModel = small
978+ a.largeModel.Set(large)
979+ a.smallModel.Set(small)
965980}
966981
967982func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
968− a.tools = tools
983+ a.tools.SetSlice(tools)
969984}
970985
971986func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
972− a.systemPrompt = systemPrompt
987+ a.systemPrompt.Set(systemPrompt)
973988}
974989
975990func (a *sessionAgent) Model() Model {
976− return a.largeModel
977−}
978−
979−func (a *sessionAgent) promptPrefix() string {
980− return a.systemPromptPrefix
991+ return a.largeModel.Get()
981992}
982993
983994// convertToToolResult converts a fantasy tool result to a message tool result.
@@ -1034,9 +1045,9 @@ func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) mes
10341045//
10351046// BEFORE: [tool result: image data]
10361047// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
1037−func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message {
1038− providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1039− a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1048+func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1049+ providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1050+ largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
10401051
10411052 if providerSupportsMedia {
10421053 return messages