1package agent
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "maps"
13 "net/http"
14 "os"
15 "path/filepath"
16 "slices"
17 "strings"
18
19 "charm.land/catwalk/pkg/catwalk"
20 "charm.land/fantasy"
21 "github.com/charmbracelet/crush/internal/agent/hyper"
22 "github.com/charmbracelet/crush/internal/agent/notify"
23 "github.com/charmbracelet/crush/internal/agent/prompt"
24 "github.com/charmbracelet/crush/internal/agent/tools"
25 "github.com/charmbracelet/crush/internal/config"
26 "github.com/charmbracelet/crush/internal/event"
27 "github.com/charmbracelet/crush/internal/filetracker"
28 "github.com/charmbracelet/crush/internal/history"
29 "github.com/charmbracelet/crush/internal/hooks"
30 "github.com/charmbracelet/crush/internal/log"
31 "github.com/charmbracelet/crush/internal/lsp"
32 "github.com/charmbracelet/crush/internal/message"
33 "github.com/charmbracelet/crush/internal/oauth/copilot"
34 "github.com/charmbracelet/crush/internal/permission"
35 "github.com/charmbracelet/crush/internal/pubsub"
36 "github.com/charmbracelet/crush/internal/session"
37 "github.com/charmbracelet/crush/internal/skills"
38 "golang.org/x/sync/errgroup"
39
40 "charm.land/fantasy/providers/anthropic"
41 "charm.land/fantasy/providers/azure"
42 "charm.land/fantasy/providers/bedrock"
43 "charm.land/fantasy/providers/google"
44 "charm.land/fantasy/providers/openai"
45 "charm.land/fantasy/providers/openaicompat"
46 "charm.land/fantasy/providers/openrouter"
47 "charm.land/fantasy/providers/vercel"
48 openaisdk "github.com/charmbracelet/openai-go/option"
49 "github.com/qjebbs/go-jsons"
50)
51
52// Coordinator errors.
53var (
54 errCoderAgentNotConfigured = errors.New("coder agent not configured")
55 errModelProviderNotConfigured = errors.New("model provider not configured")
56 errLargeModelNotSelected = errors.New("large model not selected")
57 errSmallModelNotSelected = errors.New("small model not selected")
58 errLargeModelProviderNotConfigured = errors.New("large model provider not configured")
59 errSmallModelProviderNotConfigured = errors.New("small model provider not configured")
60 errLargeModelNotFound = errors.New("large model not found in provider config")
61 errSmallModelNotFound = errors.New("small model not found in provider config")
62)
63
64// Copilot models that use the Responses API instead of Chat Completions.
65var copilotResponsesModels = map[string]bool{
66 "gpt-5.2": true,
67 "gpt-5.2-codex": true,
68 "gpt-5.3-codex": true,
69 "gpt-5.4": true,
70 "gpt-5.4-mini": true,
71 "gpt-5.5": true,
72 "gpt-5-mini": true,
73}
74
75type Coordinator interface {
76 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
77 // SetMainAgent(string)
78 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
79 Cancel(sessionID string)
80 CancelAll()
81 IsSessionBusy(sessionID string) bool
82 IsBusy() bool
83 QueuedPrompts(sessionID string) int
84 QueuedPromptsList(sessionID string) []string
85 ClearQueue(sessionID string)
86 Summarize(context.Context, string) error
87 Model() Model
88 UpdateModels(ctx context.Context) error
89}
90
91type coordinator struct {
92 cfg *config.ConfigStore
93 sessions session.Service
94 messages message.Service
95 permissions permission.Service
96 history history.Service
97 filetracker filetracker.Service
98 lspManager *lsp.Manager
99 notify pubsub.Publisher[notify.Notification]
100 runComplete pubsub.Publisher[notify.RunComplete]
101
102 currentAgent SessionAgent
103 agents map[string]SessionAgent
104
105 // Skills discovery results (session-start snapshot).
106 allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
107 activeSkills []*skills.Skill // Post-filter: active skills only.
108 skillTracker *skills.Tracker
109
110 readyWg errgroup.Group
111}
112
113func NewCoordinator(
114 ctx context.Context,
115 cfg *config.ConfigStore,
116 sessions session.Service,
117 messages message.Service,
118 permissions permission.Service,
119 history history.Service,
120 filetracker filetracker.Service,
121 lspManager *lsp.Manager,
122 notify pubsub.Publisher[notify.Notification],
123 runComplete pubsub.Publisher[notify.RunComplete],
124 skillsMgr *skills.Manager,
125) (Coordinator, error) {
126 // Skills are pre-discovered by the caller (see app.New /
127 // backend.CreateWorkspace) and passed in via the manager. If no
128 // manager was provided (legacy callers), fall back to an in-line
129 // discovery so the coordinator still works.
130 var allSkills, activeSkills []*skills.Skill
131 if skillsMgr != nil {
132 allSkills = skillsMgr.AllSkills()
133 activeSkills = skillsMgr.ActiveSkills()
134 } else {
135 allSkills, activeSkills = discoverSkills(cfg)
136 }
137 skillTracker := skills.NewTracker(activeSkills)
138
139 c := &coordinator{
140 cfg: cfg,
141 sessions: sessions,
142 messages: messages,
143 permissions: permissions,
144 history: history,
145 filetracker: filetracker,
146 lspManager: lspManager,
147 notify: notify,
148 runComplete: runComplete,
149 agents: make(map[string]SessionAgent),
150 allSkills: allSkills,
151 activeSkills: activeSkills,
152 skillTracker: skillTracker,
153 }
154
155 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
156 if !ok {
157 return nil, errCoderAgentNotConfigured
158 }
159
160 // TODO: make this dynamic when we support multiple agents
161 prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
162 if err != nil {
163 return nil, err
164 }
165
166 agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
167 if err != nil {
168 return nil, err
169 }
170 c.currentAgent = agent
171 c.agents[config.AgentCoder] = agent
172 return c, nil
173}
174
175// Run implements Coordinator.
176func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
177 if err := c.readyWg.Wait(); err != nil {
178 return nil, err
179 }
180
181 // refresh models before each run
182 if err := c.UpdateModels(ctx); err != nil {
183 return nil, fmt.Errorf("failed to update models: %w", err)
184 }
185
186 model := c.currentAgent.Model()
187 maxTokens := model.CatwalkCfg.DefaultMaxTokens
188 if model.ModelCfg.MaxTokens != 0 {
189 maxTokens = model.ModelCfg.MaxTokens
190 }
191
192 if !model.CatwalkCfg.SupportsImages && attachments != nil {
193 // filter out image attachments
194 filteredAttachments := make([]message.Attachment, 0, len(attachments))
195 for _, att := range attachments {
196 if att.IsText() {
197 filteredAttachments = append(filteredAttachments, att)
198 }
199 }
200 attachments = filteredAttachments
201 }
202
203 providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
204 if !ok {
205 return nil, errModelProviderNotConfigured
206 }
207
208 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
209
210 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
211 // NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
212 // depends on the flow below. If refresh fails, proceed with the token we have.
213 slog.Error("Failed to refresh OAuth2 token. Proceeding with existing token.", "error", err)
214 }
215
216 // Coalesce per-attempt RunComplete payloads so only the final
217 // outcome reaches subscribers. Without this, the first attempt's
218 // failed RunComplete (unauthorized) would race ahead of the
219 // retry's success, and `crush run` would exit on the stale error
220 // before ever seeing the retry result. Each attempt's
221 // SessionAgentCall.OnComplete hook overwrites latest; we publish
222 // exactly once after retries resolve, via PublishMustDeliver, so
223 // a momentarily-full subscriber buffer can't silently drop the
224 // terminal event.
225 var (
226 latest notify.RunComplete
227 hasLatest bool
228 )
229 onComplete := func(rc notify.RunComplete) {
230 latest = rc
231 hasLatest = true
232 }
233 // Propagate the caller-supplied RunID (set via agent.WithRunID
234 // at the HTTP boundary in backend.SendMessage) onto the
235 // SessionAgentCall so the terminal RunComplete event echoes it
236 // back. Both attempts in the retry chain reuse the same RunID;
237 // the coalesce closure publishes the final outcome under that
238 // same correlator.
239 runID := RunIDFromContext(ctx)
240 run := func() (*fantasy.AgentResult, error) {
241 return c.currentAgent.Run(ctx, SessionAgentCall{
242 SessionID: sessionID,
243 RunID: runID,
244 Prompt: prompt,
245 Attachments: attachments,
246 MaxOutputTokens: maxTokens,
247 ProviderOptions: mergedOptions,
248 Temperature: temp,
249 TopP: topP,
250 TopK: topK,
251 FrequencyPenalty: freqPenalty,
252 PresencePenalty: presPenalty,
253 OnComplete: onComplete,
254 })
255 }
256 beforeLoaded := c.skillTracker.LoadedNames()
257 result, originalErr := run()
258 logTurnSkillUsage(sessionID, prompt, c.activeSkills, c.skillTracker, beforeLoaded)
259
260 if c.isUnauthorized(originalErr) {
261 if err := c.retryAfterUnauthorized(ctx, providerCfg); err == nil {
262 result, originalErr = run()
263 }
264 }
265
266 if hasLatest && c.runComplete != nil {
267 c.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, latest)
268 }
269 return result, originalErr
270}
271
272func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
273 options := fantasy.ProviderOptions{}
274
275 cfgOpts := []byte("{}")
276 providerCfgOpts := []byte("{}")
277 catwalkOpts := []byte("{}")
278
279 if model.ModelCfg.ProviderOptions != nil {
280 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
281 if err == nil {
282 cfgOpts = data
283 }
284 }
285
286 if providerCfg.ProviderOptions != nil {
287 data, err := json.Marshal(providerCfg.ProviderOptions)
288 if err == nil {
289 providerCfgOpts = data
290 }
291 }
292
293 if model.CatwalkCfg.Options.ProviderOptions != nil {
294 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
295 if err == nil {
296 catwalkOpts = data
297 }
298 }
299
300 readers := []io.Reader{
301 bytes.NewReader(catwalkOpts),
302 bytes.NewReader(providerCfgOpts),
303 bytes.NewReader(cfgOpts),
304 }
305
306 got, err := jsons.Merge(readers)
307 if err != nil {
308 slog.Error("Could not merge call config", "err", err)
309 return options
310 }
311
312 mergedOptions := make(map[string]any)
313
314 err = json.Unmarshal([]byte(got), &mergedOptions)
315 if err != nil {
316 slog.Error("Could not create config for call", "err", err)
317 return options
318 }
319
320 shouldSetEffort := model.CatwalkCfg.CanReason &&
321 slices.Contains(model.CatwalkCfg.ReasoningLevels, model.ModelCfg.ReasoningEffort)
322
323 switch providerCfg.Type {
324 case openai.Name, azure.Name:
325 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
326 if !hasReasoningEffort && shouldSetEffort {
327 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
328 }
329 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
330 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
331 mergedOptions["reasoning_summary"] = "auto"
332 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
333 }
334 parsed, err := openai.ParseResponsesOptions(mergedOptions)
335 if err == nil {
336 options[openai.Name] = parsed
337 }
338 } else {
339 parsed, err := openai.ParseOptions(mergedOptions)
340 if err == nil {
341 options[openai.Name] = parsed
342 }
343 }
344 case anthropic.Name, bedrock.Name:
345 var (
346 _, hasEffort = mergedOptions["effort"]
347 _, hasThink = mergedOptions["thinking"]
348 )
349 switch {
350 case !hasEffort && shouldSetEffort:
351 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
352 case !hasThink && model.ModelCfg.Think:
353 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
354 }
355 parsed, err := anthropic.ParseOptions(mergedOptions)
356 if err == nil {
357 options[anthropic.Name] = parsed
358 }
359
360 case openrouter.Name:
361 _, hasReasoning := mergedOptions["reasoning"]
362 if !hasReasoning && shouldSetEffort {
363 mergedOptions["reasoning"] = map[string]any{
364 "enabled": true,
365 "effort": model.ModelCfg.ReasoningEffort,
366 }
367 }
368 parsed, err := openrouter.ParseOptions(mergedOptions)
369 if err == nil {
370 options[openrouter.Name] = parsed
371 }
372 case vercel.Name:
373 _, hasReasoning := mergedOptions["reasoning"]
374 if !hasReasoning && shouldSetEffort {
375 mergedOptions["reasoning"] = map[string]any{
376 "enabled": true,
377 "effort": model.ModelCfg.ReasoningEffort,
378 }
379 }
380 parsed, err := vercel.ParseOptions(mergedOptions)
381 if err == nil {
382 options[vercel.Name] = parsed
383 }
384 case google.Name:
385 _, hasReasoning := mergedOptions["thinking_config"]
386 if !hasReasoning {
387 if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
388 mergedOptions["thinking_config"] = map[string]any{
389 "thinking_budget": 2000,
390 "include_thoughts": true,
391 }
392 } else {
393 mergedOptions["thinking_config"] = map[string]any{
394 "thinking_level": model.ModelCfg.ReasoningEffort,
395 "include_thoughts": true,
396 }
397 }
398 }
399 parsed, err := google.ParseOptions(mergedOptions)
400 if err == nil {
401 options[google.Name] = parsed
402 }
403 case openaicompat.Name, hyper.Name:
404 extraBody := make(map[string]any)
405
406 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
407 if !hasReasoningEffort && shouldSetEffort {
408 switch providerCfg.ID {
409 case string(catwalk.InferenceProviderIoNet):
410 extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
411 default:
412 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
413 }
414 }
415
416 // "reasoning effort" is a standard OpenAI field, but "thinking" is not.
417 // Setting it in the right way for each provider.
418 // TODO: Abstract this in Fantasy somehow?
419 // TODO: Allow custom providers to specify how to set this?
420 switch providerCfg.ID {
421 case hyper.Name:
422 extraBody["thinking"] = model.ModelCfg.Think
423 case string(catwalk.InferenceProviderIoNet):
424 if _, ok := extraBody["reasoning"]; !ok && model.CatwalkCfg.CanReason {
425 if model.ModelCfg.Think {
426 extraBody["reasoning"] = map[string]string{"effort": "medium"}
427 } else {
428 extraBody["reasoning"] = map[string]string{"effort": "none"}
429 }
430 }
431 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
432 if model.ModelCfg.Think || model.ModelCfg.ReasoningEffort != "" {
433 extraBody["thinking"] = map[string]any{
434 "type": "enabled",
435 }
436 } else {
437 extraBody["thinking"] = map[string]any{
438 "type": "disabled",
439 }
440 }
441 case string(catwalk.InferenceProviderAlibabaSingapore):
442 if model.CatwalkCfg.CanReason {
443 extraBody["enable_thinking"] = model.ModelCfg.Think
444 }
445 }
446
447 mergedOptions["extra_body"] = extraBody
448
449 parsed, err := openaicompat.ParseOptions(mergedOptions)
450 if err == nil {
451 options[openaicompat.Name] = parsed
452 }
453 }
454
455 return options
456}
457
458func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
459 modelOptions := getProviderOptions(model, cfg)
460 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
461 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
462 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
463 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
464 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
465 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
466}
467
468func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
469 large, small, err := c.buildAgentModels(ctx, isSubAgent)
470 if err != nil {
471 return nil, err
472 }
473
474 largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
475 result := NewSessionAgent(SessionAgentOptions{
476 LargeModel: large,
477 SmallModel: small,
478 SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
479 SystemPrompt: "",
480 IsSubAgent: isSubAgent,
481 DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
482 IsYolo: c.permissions.SkipRequests(),
483 Sessions: c.sessions,
484 Messages: c.messages,
485 Tools: nil,
486 Notify: c.notify,
487 RunComplete: c.runComplete,
488 })
489
490 c.readyWg.Go(func() error {
491 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg)
492 if err != nil {
493 return err
494 }
495 result.SetSystemPrompt(systemPrompt)
496 return nil
497 })
498
499 c.readyWg.Go(func() error {
500 tools, err := c.buildTools(ctx, agent, isSubAgent)
501 if err != nil {
502 return err
503 }
504 result.SetTools(tools)
505 return nil
506 })
507
508 return result, nil
509}
510
511func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubAgent bool) ([]fantasy.AgentTool, error) {
512 var allTools []fantasy.AgentTool
513 if slices.Contains(agent.AllowedTools, AgentToolName) {
514 agentTool, err := c.agentTool(ctx)
515 if err != nil {
516 return nil, err
517 }
518 allTools = append(allTools, agentTool)
519 }
520
521 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
522 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
523 if err != nil {
524 return nil, err
525 }
526 allTools = append(allTools, agenticFetchTool)
527 }
528
529 // Get the model name for the agent
530 modelID := ""
531 if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
532 if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
533 modelID = model.ID
534 }
535 }
536
537 logFile := filepath.Join(c.cfg.Config().Options.DataDirectory, "logs", "crush.log")
538
539 // Build hook runner if PreToolUse hooks are configured.
540 var hookRunner *hooks.Runner
541 if preToolHooks := c.cfg.Config().Hooks[hooks.EventPreToolUse]; len(preToolHooks) > 0 {
542 hookRunner = hooks.NewRunner(preToolHooks, c.cfg.WorkingDir(), c.cfg.WorkingDir())
543 }
544
545 allTools = append(
546 allTools,
547 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelID),
548 tools.NewCrushInfoTool(c.cfg, c.lspManager, c.allSkills, c.activeSkills, c.skillTracker),
549 tools.NewCrushLogsTool(logFile),
550 tools.NewJobOutputTool(),
551 tools.NewJobKillTool(),
552 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
553 tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
554 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
555 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
556 tools.NewGlobTool(c.cfg.WorkingDir()),
557 tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
558 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
559 tools.NewSourcegraphTool(nil),
560 tools.NewTodosTool(c.sessions),
561 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
562 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
563 )
564
565 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
566 if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
567 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
568 }
569
570 if len(c.cfg.Config().MCP) > 0 {
571 allTools = append(
572 allTools,
573 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
574 tools.NewReadMCPResourceTool(c.cfg, c.permissions),
575 )
576 }
577
578 var filteredTools []fantasy.AgentTool
579 for _, tool := range allTools {
580 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
581 filteredTools = append(filteredTools, tool)
582 }
583 }
584
585 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
586 if agent.AllowedMCP == nil {
587 // No MCP restrictions
588 filteredTools = append(filteredTools, tool)
589 continue
590 }
591 if len(agent.AllowedMCP) == 0 {
592 // No MCPs allowed
593 slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
594 break
595 }
596
597 for mcp, tools := range agent.AllowedMCP {
598 if mcp != tool.MCP() {
599 continue
600 }
601 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
602 filteredTools = append(filteredTools, tool)
603 break
604 }
605 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
606 }
607 }
608 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
609 return strings.Compare(a.Info().Name, b.Info().Name)
610 })
611
612 // Wrap tools with hook interception for the top-level agent only.
613 // Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run
614 // without hook interception to avoid firing the user's hook N times
615 // per delegated turn. The top-level invocation of the sub-agent tool
616 // itself is still wrapped from the coder's side.
617 filteredTools = wrapToolsWithHooks(filteredTools, hookRunner, isSubAgent)
618
619 return filteredTools, nil
620}
621
622// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
623func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
624 largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
625 if !ok {
626 return Model{}, Model{}, errLargeModelNotSelected
627 }
628 smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
629 if !ok {
630 return Model{}, Model{}, errSmallModelNotSelected
631 }
632
633 largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
634 if !ok {
635 return Model{}, Model{}, errLargeModelProviderNotConfigured
636 }
637
638 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
639 if err != nil {
640 return Model{}, Model{}, err
641 }
642
643 smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
644 if !ok {
645 return Model{}, Model{}, errSmallModelProviderNotConfigured
646 }
647
648 smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
649 if err != nil {
650 return Model{}, Model{}, err
651 }
652
653 var largeCatwalkModel *catwalk.Model
654 var smallCatwalkModel *catwalk.Model
655
656 for _, m := range largeProviderCfg.Models {
657 if m.ID == largeModelCfg.Model {
658 largeCatwalkModel = &m
659 }
660 }
661 for _, m := range smallProviderCfg.Models {
662 if m.ID == smallModelCfg.Model {
663 smallCatwalkModel = &m
664 }
665 }
666
667 if largeCatwalkModel == nil {
668 return Model{}, Model{}, errLargeModelNotFound
669 }
670
671 if smallCatwalkModel == nil {
672 return Model{}, Model{}, errSmallModelNotFound
673 }
674
675 largeModelID := largeModelCfg.Model
676 smallModelID := smallModelCfg.Model
677
678 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
679 largeModelID += ":exacto"
680 }
681
682 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
683 smallModelID += ":exacto"
684 }
685
686 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
687 if err != nil {
688 return Model{}, Model{}, err
689 }
690 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
691 if err != nil {
692 return Model{}, Model{}, err
693 }
694
695 return Model{
696 Model: largeModel,
697 CatwalkCfg: *largeCatwalkModel,
698 ModelCfg: largeModelCfg,
699 FlatRate: largeProviderCfg.FlatRate,
700 }, Model{
701 Model: smallModel,
702 CatwalkCfg: *smallCatwalkModel,
703 ModelCfg: smallModelCfg,
704 FlatRate: smallProviderCfg.FlatRate,
705 }, nil
706}
707
708func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
709 var opts []anthropic.Option
710
711 switch {
712 case strings.HasPrefix(apiKey, "Bearer "):
713 // NOTE: Prevent the SDK from picking up the API key from env.
714 os.Setenv("ANTHROPIC_API_KEY", "")
715 headers["Authorization"] = apiKey
716 case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina):
717 // NOTE: Prevent the SDK from picking up the API key from env.
718 os.Setenv("ANTHROPIC_API_KEY", "")
719 headers["Authorization"] = "Bearer " + apiKey
720 case apiKey != "":
721 // X-Api-Key header
722 opts = append(opts, anthropic.WithAPIKey(apiKey))
723 }
724
725 if len(headers) > 0 {
726 opts = append(opts, anthropic.WithHeaders(headers))
727 }
728
729 if baseURL != "" {
730 opts = append(opts, anthropic.WithBaseURL(baseURL))
731 }
732
733 if c.cfg.Config().Options.Debug {
734 httpClient := log.NewHTTPClient()
735 opts = append(opts, anthropic.WithHTTPClient(httpClient))
736 }
737 return anthropic.New(opts...)
738}
739
740func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
741 opts := []openai.Option{
742 openai.WithAPIKey(apiKey),
743 openai.WithUseResponsesAPI(),
744 }
745 if c.cfg.Config().Options.Debug {
746 httpClient := log.NewHTTPClient()
747 opts = append(opts, openai.WithHTTPClient(httpClient))
748 }
749 if len(headers) > 0 {
750 opts = append(opts, openai.WithHeaders(headers))
751 }
752 if baseURL != "" {
753 opts = append(opts, openai.WithBaseURL(baseURL))
754 }
755 return openai.New(opts...)
756}
757
758func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
759 opts := []openrouter.Option{
760 openrouter.WithAPIKey(apiKey),
761 }
762 if c.cfg.Config().Options.Debug {
763 httpClient := log.NewHTTPClient()
764 opts = append(opts, openrouter.WithHTTPClient(httpClient))
765 }
766 if len(headers) > 0 {
767 opts = append(opts, openrouter.WithHeaders(headers))
768 }
769 return openrouter.New(opts...)
770}
771
772func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
773 opts := []vercel.Option{
774 vercel.WithAPIKey(apiKey),
775 }
776 if c.cfg.Config().Options.Debug {
777 httpClient := log.NewHTTPClient()
778 opts = append(opts, vercel.WithHTTPClient(httpClient))
779 }
780 if len(headers) > 0 {
781 opts = append(opts, vercel.WithHeaders(headers))
782 }
783 return vercel.New(opts...)
784}
785
786func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
787 opts := []openaicompat.Option{
788 openaicompat.WithBaseURL(baseURL),
789 openaicompat.WithAPIKey(apiKey),
790 }
791
792 // Set HTTP client based on provider and debug mode.
793 var httpClient *http.Client
794 if providerID == string(catwalk.InferenceProviderCopilot) {
795 opts = append(
796 opts,
797 openaicompat.WithUseResponsesAPI(),
798 openaicompat.WithResponsesAPIFunc(func(modelID string) bool {
799 return copilotResponsesModels[modelID]
800 }),
801 )
802 httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
803 } else if c.cfg.Config().Options.Debug {
804 httpClient = log.NewHTTPClient()
805 }
806 if httpClient != nil {
807 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
808 }
809
810 if len(headers) > 0 {
811 opts = append(opts, openaicompat.WithHeaders(headers))
812 }
813
814 for extraKey, extraValue := range extraBody {
815 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
816 }
817
818 return openaicompat.New(opts...)
819}
820
821func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
822 opts := []azure.Option{
823 azure.WithBaseURL(baseURL),
824 azure.WithAPIKey(apiKey),
825 azure.WithUseResponsesAPI(),
826 }
827 if c.cfg.Config().Options.Debug {
828 httpClient := log.NewHTTPClient()
829 opts = append(opts, azure.WithHTTPClient(httpClient))
830 }
831 if options == nil {
832 options = make(map[string]string)
833 }
834 if apiVersion, ok := options["apiVersion"]; ok {
835 opts = append(opts, azure.WithAPIVersion(apiVersion))
836 }
837 if len(headers) > 0 {
838 opts = append(opts, azure.WithHeaders(headers))
839 }
840
841 return azure.New(opts...)
842}
843
844func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
845 var opts []bedrock.Option
846 if c.cfg.Config().Options.Debug {
847 httpClient := log.NewHTTPClient()
848 opts = append(opts, bedrock.WithHTTPClient(httpClient))
849 }
850 if len(headers) > 0 {
851 opts = append(opts, bedrock.WithHeaders(headers))
852 }
853
854 switch {
855 case apiKey != "":
856 opts = append(opts, bedrock.WithAPIKey(apiKey))
857 case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
858 opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
859 default:
860 // Skip, let the SDK do authentication.
861 }
862
863 switch providerID {
864 case string(catwalk.InferenceProviderBedrockEurope):
865 opts = append(opts, bedrock.WithRegion("eu-west-1"))
866 default:
867 opts = append(opts, bedrock.WithRegion("us-east-1"))
868 }
869
870 return bedrock.New(opts...)
871}
872
873func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
874 opts := []google.Option{
875 google.WithBaseURL(baseURL),
876 google.WithGeminiAPIKey(apiKey),
877 }
878 if c.cfg.Config().Options.Debug {
879 httpClient := log.NewHTTPClient()
880 opts = append(opts, google.WithHTTPClient(httpClient))
881 }
882 if len(headers) > 0 {
883 opts = append(opts, google.WithHeaders(headers))
884 }
885 return google.New(opts...)
886}
887
888func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
889 opts := []google.Option{}
890 if c.cfg.Config().Options.Debug {
891 httpClient := log.NewHTTPClient()
892 opts = append(opts, google.WithHTTPClient(httpClient))
893 }
894 if len(headers) > 0 {
895 opts = append(opts, google.WithHeaders(headers))
896 }
897
898 project := options["project"]
899 location := options["location"]
900
901 opts = append(opts, google.WithVertex(project, location))
902
903 return google.New(opts...)
904}
905
906func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
907 if model.Think {
908 return true
909 }
910 opts, err := anthropic.ParseOptions(model.ProviderOptions)
911 return err == nil && opts.Thinking != nil
912}
913
914func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
915 headers := maps.Clone(providerCfg.ExtraHeaders)
916 if headers == nil {
917 headers = make(map[string]string)
918 }
919
920 // handle special headers for anthropic
921 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
922 if v, ok := headers["anthropic-beta"]; ok {
923 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
924 } else {
925 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
926 }
927 }
928
929 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
930 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
931
932 switch providerCfg.Type {
933 case openai.Name:
934 return c.buildOpenaiProvider(baseURL, apiKey, headers)
935 case anthropic.Name:
936 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
937 case openrouter.Name:
938 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
939 case vercel.Name:
940 return c.buildVercelProvider(baseURL, apiKey, headers)
941 case azure.Name:
942 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
943 case bedrock.Name:
944 return c.buildBedrockProvider(apiKey, headers, providerCfg.ID)
945 case google.Name:
946 return c.buildGoogleProvider(baseURL, apiKey, headers)
947 case "google-vertex":
948 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
949 case openaicompat.Name, hyper.Name:
950 switch providerCfg.ID {
951 case hyper.Name:
952 baseURL = hyper.BaseURL() + "/v1"
953 headers["x-crush-id"] = event.GetID()
954 case string(catwalk.InferenceProviderZAI):
955 if providerCfg.ExtraBody == nil {
956 providerCfg.ExtraBody = map[string]any{}
957 }
958 providerCfg.ExtraBody["tool_stream"] = true
959 }
960 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
961 default:
962 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
963 }
964}
965
966func isExactoSupported(modelID string) bool {
967 supportedModels := []string{
968 "moonshotai/kimi-k2-0905",
969 "deepseek/deepseek-v3.1-terminus",
970 "z-ai/glm-4.6",
971 "openai/gpt-oss-120b",
972 "qwen/qwen3-coder",
973 }
974 return slices.Contains(supportedModels, modelID)
975}
976
977func (c *coordinator) Cancel(sessionID string) {
978 c.currentAgent.Cancel(sessionID)
979}
980
981func (c *coordinator) CancelAll() {
982 c.currentAgent.CancelAll()
983}
984
985func (c *coordinator) ClearQueue(sessionID string) {
986 c.currentAgent.ClearQueue(sessionID)
987}
988
989func (c *coordinator) IsBusy() bool {
990 return c.currentAgent.IsBusy()
991}
992
993func (c *coordinator) IsSessionBusy(sessionID string) bool {
994 return c.currentAgent.IsSessionBusy(sessionID)
995}
996
997func (c *coordinator) Model() Model {
998 return c.currentAgent.Model()
999}
1000
1001func (c *coordinator) UpdateModels(ctx context.Context) error {
1002 // build the models again so we make sure we get the latest config
1003 large, small, err := c.buildAgentModels(ctx, false)
1004 if err != nil {
1005 return err
1006 }
1007 c.currentAgent.SetModels(large, small)
1008
1009 agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
1010 if !ok {
1011 return errCoderAgentNotConfigured
1012 }
1013
1014 tools, err := c.buildTools(ctx, agentCfg, false)
1015 if err != nil {
1016 return err
1017 }
1018 c.currentAgent.SetTools(tools)
1019 return nil
1020}
1021
1022func (c *coordinator) QueuedPrompts(sessionID string) int {
1023 return c.currentAgent.QueuedPrompts(sessionID)
1024}
1025
1026func (c *coordinator) QueuedPromptsList(sessionID string) []string {
1027 return c.currentAgent.QueuedPromptsList(sessionID)
1028}
1029
1030func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
1031 providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
1032 if !ok {
1033 return errModelProviderNotConfigured
1034 }
1035
1036 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
1037 slog.Error("Failed to refresh OAuth2 token before summarize. Proceeding with existing token.", "error", err)
1038 }
1039
1040 summarize := func() error {
1041 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
1042 }
1043
1044 err := summarize()
1045 if err != nil && c.isUnauthorized(err) {
1046 if retryErr := c.retryAfterUnauthorized(ctx, providerCfg); retryErr == nil {
1047 return summarize()
1048 }
1049 }
1050
1051 return err
1052}
1053
1054// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
1055func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
1056 if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
1057 return nil
1058 }
1059 slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
1060 return c.refreshOAuth2Token(ctx, providerCfg)
1061}
1062
1063// retryAfterUnauthorized attempts to refresh credentials after receiving a 401
1064// and returns nil if retry should be attempted.
1065func (c *coordinator) retryAfterUnauthorized(ctx context.Context, providerCfg config.ProviderConfig) error {
1066 switch {
1067 case providerCfg.OAuthToken != nil:
1068 slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
1069 return c.refreshOAuth2Token(ctx, providerCfg)
1070 case strings.Contains(providerCfg.APIKeyTemplate, "$"):
1071 slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
1072 return c.refreshApiKeyTemplate(ctx, providerCfg)
1073 default:
1074 return nil
1075 }
1076}
1077
1078func (c *coordinator) isUnauthorized(err error) bool {
1079 var providerErr *fantasy.ProviderError
1080 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
1081}
1082
1083func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
1084 if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
1085 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
1086 return err
1087 }
1088 if err := c.UpdateModels(ctx); err != nil {
1089 return err
1090 }
1091 return nil
1092}
1093
1094func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
1095 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
1096 if err != nil {
1097 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
1098 return err
1099 }
1100
1101 providerCfg.APIKey = newAPIKey
1102 c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
1103
1104 if err := c.UpdateModels(ctx); err != nil {
1105 return err
1106 }
1107 return nil
1108}
1109
1110// subAgentParams holds the parameters for running a sub-agent.
1111type subAgentParams struct {
1112 Agent SessionAgent
1113 SessionID string
1114 AgentMessageID string
1115 ToolCallID string
1116 Prompt string
1117 SessionTitle string
1118 // SessionSetup is an optional callback invoked after session creation
1119 // but before agent execution, for custom session configuration.
1120 SessionSetup func(sessionID string)
1121}
1122
1123// runSubAgent runs a sub-agent and handles session management and cost accumulation.
1124// It creates a sub-session, runs the agent with the given prompt, and propagates
1125// the cost to the parent session.
1126func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
1127 // Create sub-session
1128 agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
1129 session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
1130 if err != nil {
1131 return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
1132 }
1133
1134 // Call session setup function if provided
1135 if params.SessionSetup != nil {
1136 params.SessionSetup(session.ID)
1137 }
1138
1139 // Get model configuration
1140 model := params.Agent.Model()
1141 maxTokens := model.CatwalkCfg.DefaultMaxTokens
1142 if model.ModelCfg.MaxTokens != 0 {
1143 maxTokens = model.ModelCfg.MaxTokens
1144 }
1145
1146 providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
1147 if !ok {
1148 return fantasy.ToolResponse{}, errModelProviderNotConfigured
1149 }
1150
1151 // Run the agent
1152 result, err := params.Agent.Run(ctx, SessionAgentCall{
1153 SessionID: session.ID,
1154 Prompt: params.Prompt,
1155 MaxOutputTokens: maxTokens,
1156 ProviderOptions: getProviderOptions(model, providerCfg),
1157 Temperature: model.ModelCfg.Temperature,
1158 TopP: model.ModelCfg.TopP,
1159 TopK: model.ModelCfg.TopK,
1160 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1161 PresencePenalty: model.ModelCfg.PresencePenalty,
1162 NonInteractive: true,
1163 })
1164 if err != nil {
1165 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to generate response: %s", err)), nil
1166 }
1167
1168 // Update parent session cost
1169 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1170 return fantasy.ToolResponse{}, err
1171 }
1172
1173 return fantasy.NewTextResponse(result.Response.Content.Text()), nil
1174}
1175
1176// updateParentSessionCost accumulates the cost from a child session to its parent session.
1177func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
1178 childSession, err := c.sessions.Get(ctx, childSessionID)
1179 if err != nil {
1180 return fmt.Errorf("get child session: %w", err)
1181 }
1182
1183 parentSession, err := c.sessions.Get(ctx, parentSessionID)
1184 if err != nil {
1185 return fmt.Errorf("get parent session: %w", err)
1186 }
1187
1188 parentSession.Cost += childSession.Cost
1189
1190 if _, err := c.sessions.Save(ctx, parentSession); err != nil {
1191 return fmt.Errorf("save parent session: %w", err)
1192 }
1193
1194 return nil
1195}
1196
1197// discoverSkills is a thin fallback wrapper used only when no
1198// skills.Manager has been threaded through to the coordinator. All
1199// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
1200// run discovery in advance and pass the results via the manager;
1201// reaching this path means a caller bypassed both. It deliberately does
1202// NOT publish to the package-level broker — there are no subscribers in
1203// that case, so doing so would be misleading without delivering the
1204// snapshot anywhere useful.
1205func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1206 opts := cfg.Config().Options
1207 var paths, disabled []string
1208 if opts != nil {
1209 paths = opts.SkillsPaths
1210 disabled = opts.DisabledSkills
1211 }
1212 var resolver func(string) (string, error)
1213 if r := cfg.Resolver(); r != nil {
1214 resolver = r.ResolveValue
1215 }
1216 allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
1217 SkillsPaths: paths,
1218 DisabledSkills: disabled,
1219 Resolver: resolver,
1220 })
1221 logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
1222 return allSkills, activeSkills
1223}
1224
1225// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
1226// (if any) were loaded during this turn and which looked relevant based on
1227// a cheap keyword match against the user prompt. The goal is to surface
1228// "should-have-loaded but didn't" situations for later analysis.
1229//
1230// Logged at Info level under component=skills; heavy fields are elided when
1231// there is nothing interesting to report.
1232func logTurnSkillUsage(
1233 sessionID string,
1234 prompt string,
1235 activeSkills []*skills.Skill,
1236 tracker *skills.Tracker,
1237 before []string,
1238) {
1239 if tracker == nil || len(activeSkills) == 0 {
1240 return
1241 }
1242
1243 after := tracker.LoadedNames()
1244
1245 beforeSet := make(map[string]bool, len(before))
1246 for _, n := range before {
1247 beforeSet[n] = true
1248 }
1249 var loadedThisTurn []string
1250 for _, n := range after {
1251 if !beforeSet[n] {
1252 loadedThisTurn = append(loadedThisTurn, n)
1253 }
1254 }
1255
1256 slog.Info(
1257 "Skill turn summary",
1258 "component", "skills",
1259 "session_id", sessionID,
1260 "prompt_len", len(prompt),
1261 "active_total", len(activeSkills),
1262 "loaded_total", len(after),
1263 "loaded_this_turn", loadedThisTurn,
1264 )
1265}
1266
1267// logDiscoveryStats emits a single structured log line summarising skill
1268// discovery for the current session. It is intentionally low-volume: one
1269// line per session start. Builtin vs user counts are derived from the
1270// SkillState.Path — builtin states use the "builtin/" embed prefix.
1271func logDiscoveryStats(
1272 states []*skills.SkillState,
1273 userPaths []string,
1274 allSkills, activeSkills []*skills.Skill,
1275 disabled []string,
1276) {
1277 var builtinOK, builtinErr, userOK, userErr int
1278 for _, s := range states {
1279 isBuiltin := strings.HasPrefix(s.Path, "builtin/")
1280 switch {
1281 case isBuiltin && s.State == skills.StateNormal:
1282 builtinOK++
1283 case isBuiltin && s.State == skills.StateError:
1284 builtinErr++
1285 case !isBuiltin && s.State == skills.StateNormal:
1286 userOK++
1287 case !isBuiltin && s.State == skills.StateError:
1288 userErr++
1289 }
1290 }
1291
1292 activeNames := make([]string, 0, len(activeSkills))
1293 for _, s := range activeSkills {
1294 activeNames = append(activeNames, s.Name)
1295 }
1296
1297 xml := skills.ToPromptXML(activeSkills)
1298
1299 slog.Info(
1300 "Skill discovery complete",
1301 "component", "skills",
1302 "builtin_ok", builtinOK,
1303 "builtin_errors", builtinErr,
1304 "user_ok", userOK,
1305 "user_errors", userErr,
1306 "user_paths", len(userPaths),
1307 "deduped_total", len(allSkills),
1308 "active", len(activeSkills),
1309 "disabled", len(disabled),
1310 "prompt_bytes", len(xml),
1311 "prompt_tok_est", skills.ApproxTokenCount(xml),
1312 "active_names", activeNames,
1313 )
1314}