on main help sign in
perf(agent): O(1) IsBusy, provider client cache, gated tool copies

- IsBusy: use activeRequests.Len() instead of cloning the whole map on every (frequent) call; entries are never nil so the length is exact. - coordinator: cache built provider clients keyed on their fully resolved config so the HTTP keep-alive pool survives across turns (UpdateModels rebuilds models every turn). Any credential/endpoint/ model change misses the cache and rebuilds. - PrepareStep: reuse the previous step's tool copy unless SetTools bumped a generation counter, avoiding a full slice copy every step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commitb10b0e1bc25e322a7bd9701fdbbcabc08f0e4f76 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:17 (3 months ago) parent9492d7d
2 files changed+92−9
internal/agent/agent.go+21−9 internal/agent/coordinator.go+71
internal/agent/agent.go+21−9
@@ -157,6 +157,7 @@ type sessionAgent struct {
157157 systemPromptPrefix *csync.Value[string]
158158 systemPrompt *csync.Value[string]
159159 tools *csync.Slice[fantasy.AgentTool]
160+ toolsGen atomic.Uint64 // bumped by SetTools; lets PrepareStep skip re-copying unchanged tools
160161
161162 isSubAgent bool
162163 sessions session.Service
@@ -642,6 +643,13 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
642643
643644 // Copy mutable fields under lock to avoid races with SetTools/SetModels.
644645 agentTools := a.tools.Copy()
646+ // Per-step tool snapshot reused across PrepareStep calls; only
647+ // re-copied when SetTools bumps toolsGen (see PrepareStep below).
648+ var (
649+ stepTools []fantasy.AgentTool
650+ stepToolsGen uint64
651+ stepToolsInit bool
652+ )
645653 largeModel := a.largeModel.Get()
646654 systemPrompt := a.systemPrompt.Get()
647655 promptPrefix := a.systemPromptPrefix.Get()
@@ -806,7 +814,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
806814 }
807815
808816 // Use latest tools (updated by SetTools when MCP tools change).
809− prepared.Tools = a.tools.Copy()
817+ // Reuse the previous step's copy unless SetTools has bumped the
818+ // generation, so an unchanged tool set isn't re-copied every step.
819+ if gen := a.toolsGen.Load(); !stepToolsInit || gen != stepToolsGen {
820+ stepTools = a.tools.Copy()
821+ stepToolsGen = gen
822+ stepToolsInit = true
823+ }
824+ prepared.Tools = stepTools
810825
811826 // Drain queued follow-up prompts for this step. Calls covered
812827 // by a cancel recorded while they sat in the queue are dropped:
@@ -1975,14 +1990,10 @@ func (a *sessionAgent) CancelAll() {
19751990}
19761991
19771992func (a *sessionAgent) IsBusy() bool {
1978− var busy bool
1979− for cancelFunc := range a.activeRequests.Seq() {
1980− if cancelFunc != nil {
1981− busy = true
1982− break
1983− }
1984− }
1985− return busy
1993+ // activeRequests only ever holds non-nil cancel funcs (Set with a
1994+ // real func, removed via Del), so a length check is exact — and it
1995+ // avoids cloning the whole map, which Seq() does on every call.
1996+ return a.activeRequests.Len() > 0
19861997}
19871998
19881999func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
@@ -2017,6 +2028,7 @@ func (a *sessionAgent) SetModels(large Model, small Model) {
20172028
20182029func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
20192030 a.tools.SetSlice(tools)
2031+ a.toolsGen.Add(1)
20202032}
20212033
20222034func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
internal/agent/coordinator.go+71
@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/agent/prompt"
2424 "github.com/charmbracelet/crush/internal/agent/tools"
2525 "github.com/charmbracelet/crush/internal/config"
26+ "github.com/charmbracelet/crush/internal/csync"
2627 "github.com/charmbracelet/crush/internal/discover"
2728 "github.com/charmbracelet/crush/internal/event"
2829 "github.com/charmbracelet/crush/internal/filetracker"
@@ -123,6 +124,14 @@ type coordinator struct {
123124 activeSkills []*skills.Skill // Post-filter: active skills only.
124125 skillTracker *skills.Tracker
125126
127+ // providerClients caches built provider clients keyed by their fully
128+ // resolved configuration. UpdateModels rebuilds models every turn;
129+ // without this, each turn discards the provider's HTTP client and its
130+ // keep-alive connection pool, forcing a fresh TLS handshake. The key
131+ // captures every input buildProvider depends on, so any config/model/
132+ // credential change misses the cache and rebuilds.
133+ providerClients *csync.Map[string, fantasy.Provider]
134+
126135 readyWg errgroup.Group
127136}
128137
@@ -166,6 +175,8 @@ func NewCoordinator(
166175 allSkills: allSkills,
167176 activeSkills: activeSkills,
168177 skillTracker: skillTracker,
178+
179+ providerClients: csync.NewMap[string, fantasy.Provider](),
169180 }
170181
171182 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
@@ -1043,6 +1054,66 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
10431054 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
10441055 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
10451056
1057+ // Reuse a previously built client when nothing that affects it has
1058+ // changed, so the HTTP keep-alive pool survives across turns. The key
1059+ // is derived from the resolved inputs above (plus the debug flag); the
1060+ // per-provider adjustments inside the switch below are deterministic
1061+ // functions of these, so identical inputs always yield an identical
1062+ // client.
1063+ cacheKey := c.providerCacheKey(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1064+ if p, ok := c.providerClients.Get(cacheKey); ok {
1065+ return p, nil
1066+ }
1067+ provider, err := c.buildProviderUncached(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1068+ if err != nil {
1069+ return nil, err
1070+ }
1071+ c.providerClients.Set(cacheKey, provider)
1072+ return provider, nil
1073+}
1074+
1075+// providerCacheKey builds a stable key from every input buildProvider's
1076+// switch consumes, so any credential, endpoint, header, or option change
1077+// produces a distinct key (and thus a rebuilt client).
1078+func (c *coordinator) providerCacheKey(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) string {
1079+ var b strings.Builder
1080+ b.WriteString(providerCfg.ID)
1081+ b.WriteByte(0)
1082+ b.WriteString(string(providerCfg.Type))
1083+ b.WriteByte(0)
1084+ // The opencode providers pick their client type from the model, so the
1085+ // model must participate in the key.
1086+ b.WriteString(model.Model)
1087+ b.WriteByte(0)
1088+ b.WriteString(apiKey)
1089+ b.WriteByte(0)
1090+ b.WriteString(baseURL)
1091+ b.WriteByte(0)
1092+ for _, k := range slices.Sorted(maps.Keys(headers)) {
1093+ b.WriteString(k)
1094+ b.WriteByte('=')
1095+ b.WriteString(headers[k])
1096+ b.WriteByte(1)
1097+ }
1098+ b.WriteByte(0)
1099+ if extra, err := json.Marshal(providerCfg.ExtraBody); err == nil {
1100+ b.Write(extra)
1101+ }
1102+ b.WriteByte(0)
1103+ if extra, err := json.Marshal(providerCfg.ExtraParams); err == nil {
1104+ b.Write(extra)
1105+ }
1106+ b.WriteByte(0)
1107+ if isSubAgent {
1108+ b.WriteByte('s')
1109+ }
1110+ if c.cfg.Config().Options.Debug {
1111+ b.WriteByte('d')
1112+ }
1113+ return b.String()
1114+}
1115+
1116+func (c *coordinator) buildProviderUncached(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) (fantasy.Provider, error) {
10461117 switch providerCfg.ID {
10471118 case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
10481119 if opencodeMessagesModels[model.Model] {