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