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