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