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