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