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