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