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