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) (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 switch {
819 case apiKey != "":
820 opts = append(opts, bedrock.WithAPIKey(apiKey))
821 case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
822 opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
823 default:
824 // Skip, let the SDK do authentication.
825 }
826 return bedrock.New(opts...)
827}
828
829func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
830 opts := []google.Option{
831 google.WithBaseURL(baseURL),
832 google.WithGeminiAPIKey(apiKey),
833 }
834 if c.cfg.Config().Options.Debug {
835 httpClient := log.NewHTTPClient()
836 opts = append(opts, google.WithHTTPClient(httpClient))
837 }
838 if len(headers) > 0 {
839 opts = append(opts, google.WithHeaders(headers))
840 }
841 return google.New(opts...)
842}
843
844func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
845 opts := []google.Option{}
846 if c.cfg.Config().Options.Debug {
847 httpClient := log.NewHTTPClient()
848 opts = append(opts, google.WithHTTPClient(httpClient))
849 }
850 if len(headers) > 0 {
851 opts = append(opts, google.WithHeaders(headers))
852 }
853
854 project := options["project"]
855 location := options["location"]
856
857 opts = append(opts, google.WithVertex(project, location))
858
859 return google.New(opts...)
860}
861
862func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
863 if model.Think {
864 return true
865 }
866 opts, err := anthropic.ParseOptions(model.ProviderOptions)
867 return err == nil && opts.Thinking != nil
868}
869
870func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
871 headers := maps.Clone(providerCfg.ExtraHeaders)
872 if headers == nil {
873 headers = make(map[string]string)
874 }
875
876 // handle special headers for anthropic
877 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
878 if v, ok := headers["anthropic-beta"]; ok {
879 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
880 } else {
881 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
882 }
883 }
884
885 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
886 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
887
888 switch providerCfg.Type {
889 case openai.Name:
890 return c.buildOpenaiProvider(baseURL, apiKey, headers)
891 case anthropic.Name:
892 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
893 case openrouter.Name:
894 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
895 case vercel.Name:
896 return c.buildVercelProvider(baseURL, apiKey, headers)
897 case azure.Name:
898 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
899 case bedrock.Name:
900 return c.buildBedrockProvider(apiKey, headers)
901 case google.Name:
902 return c.buildGoogleProvider(baseURL, apiKey, headers)
903 case "google-vertex":
904 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
905 case openaicompat.Name, hyper.Name:
906 switch providerCfg.ID {
907 case hyper.Name:
908 baseURL = hyper.BaseURL() + "/v1"
909 headers["x-crush-id"] = event.GetID()
910 case string(catwalk.InferenceProviderZAI):
911 if providerCfg.ExtraBody == nil {
912 providerCfg.ExtraBody = map[string]any{}
913 }
914 providerCfg.ExtraBody["tool_stream"] = true
915 }
916 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
917 default:
918 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
919 }
920}
921
922func isExactoSupported(modelID string) bool {
923 supportedModels := []string{
924 "moonshotai/kimi-k2-0905",
925 "deepseek/deepseek-v3.1-terminus",
926 "z-ai/glm-4.6",
927 "openai/gpt-oss-120b",
928 "qwen/qwen3-coder",
929 }
930 return slices.Contains(supportedModels, modelID)
931}
932
933func (c *coordinator) Cancel(sessionID string) {
934 c.currentAgent.Cancel(sessionID)
935}
936
937func (c *coordinator) CancelAll() {
938 c.currentAgent.CancelAll()
939}
940
941func (c *coordinator) ClearQueue(sessionID string) {
942 c.currentAgent.ClearQueue(sessionID)
943}
944
945func (c *coordinator) IsBusy() bool {
946 return c.currentAgent.IsBusy()
947}
948
949func (c *coordinator) IsSessionBusy(sessionID string) bool {
950 return c.currentAgent.IsSessionBusy(sessionID)
951}
952
953func (c *coordinator) Model() Model {
954 return c.currentAgent.Model()
955}
956
957func (c *coordinator) UpdateModels(ctx context.Context) error {
958 // build the models again so we make sure we get the latest config
959 large, small, err := c.buildAgentModels(ctx, false)
960 if err != nil {
961 return err
962 }
963 c.currentAgent.SetModels(large, small)
964
965 agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
966 if !ok {
967 return errCoderAgentNotConfigured
968 }
969
970 tools, err := c.buildTools(ctx, agentCfg, false)
971 if err != nil {
972 return err
973 }
974 c.currentAgent.SetTools(tools)
975 return nil
976}
977
978func (c *coordinator) QueuedPrompts(sessionID string) int {
979 return c.currentAgent.QueuedPrompts(sessionID)
980}
981
982func (c *coordinator) QueuedPromptsList(sessionID string) []string {
983 return c.currentAgent.QueuedPromptsList(sessionID)
984}
985
986func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
987 providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
988 if !ok {
989 return errModelProviderNotConfigured
990 }
991
992 if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
993 slog.Error("Failed to refresh OAuth2 token before summarize. Proceeding with existing token.", "error", err)
994 }
995
996 summarize := func() error {
997 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
998 }
999
1000 err := summarize()
1001 if err != nil && c.isUnauthorized(err) {
1002 if retryErr := c.retryAfterUnauthorized(ctx, providerCfg); retryErr == nil {
1003 return summarize()
1004 }
1005 }
1006
1007 return err
1008}
1009
1010// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
1011func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
1012 if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
1013 return nil
1014 }
1015 slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
1016 return c.refreshOAuth2Token(ctx, providerCfg)
1017}
1018
1019// retryAfterUnauthorized attempts to refresh credentials after receiving a 401
1020// and returns nil if retry should be attempted.
1021func (c *coordinator) retryAfterUnauthorized(ctx context.Context, providerCfg config.ProviderConfig) error {
1022 switch {
1023 case providerCfg.OAuthToken != nil:
1024 slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
1025 return c.refreshOAuth2Token(ctx, providerCfg)
1026 case strings.Contains(providerCfg.APIKeyTemplate, "$"):
1027 slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
1028 return c.refreshApiKeyTemplate(ctx, providerCfg)
1029 default:
1030 return nil
1031 }
1032}
1033
1034func (c *coordinator) isUnauthorized(err error) bool {
1035 var providerErr *fantasy.ProviderError
1036 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
1037}
1038
1039func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
1040 if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
1041 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
1042 return err
1043 }
1044 if err := c.UpdateModels(ctx); err != nil {
1045 return err
1046 }
1047 return nil
1048}
1049
1050func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
1051 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
1052 if err != nil {
1053 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
1054 return err
1055 }
1056
1057 providerCfg.APIKey = newAPIKey
1058 c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
1059
1060 if err := c.UpdateModels(ctx); err != nil {
1061 return err
1062 }
1063 return nil
1064}
1065
1066// subAgentParams holds the parameters for running a sub-agent.
1067type subAgentParams struct {
1068 Agent SessionAgent
1069 SessionID string
1070 AgentMessageID string
1071 ToolCallID string
1072 Prompt string
1073 SessionTitle string
1074 // SessionSetup is an optional callback invoked after session creation
1075 // but before agent execution, for custom session configuration.
1076 SessionSetup func(sessionID string)
1077}
1078
1079// runSubAgent runs a sub-agent and handles session management and cost accumulation.
1080// It creates a sub-session, runs the agent with the given prompt, and propagates
1081// the cost to the parent session.
1082func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
1083 // Create sub-session
1084 agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
1085 session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
1086 if err != nil {
1087 return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
1088 }
1089
1090 // Call session setup function if provided
1091 if params.SessionSetup != nil {
1092 params.SessionSetup(session.ID)
1093 }
1094
1095 // Get model configuration
1096 model := params.Agent.Model()
1097 maxTokens := model.CatwalkCfg.DefaultMaxTokens
1098 if model.ModelCfg.MaxTokens != 0 {
1099 maxTokens = model.ModelCfg.MaxTokens
1100 }
1101
1102 providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
1103 if !ok {
1104 return fantasy.ToolResponse{}, errModelProviderNotConfigured
1105 }
1106
1107 // Run the agent
1108 result, err := params.Agent.Run(ctx, SessionAgentCall{
1109 SessionID: session.ID,
1110 Prompt: params.Prompt,
1111 MaxOutputTokens: maxTokens,
1112 ProviderOptions: getProviderOptions(model, providerCfg),
1113 Temperature: model.ModelCfg.Temperature,
1114 TopP: model.ModelCfg.TopP,
1115 TopK: model.ModelCfg.TopK,
1116 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1117 PresencePenalty: model.ModelCfg.PresencePenalty,
1118 NonInteractive: true,
1119 })
1120 if err != nil {
1121 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to generate response: %s", err)), nil
1122 }
1123
1124 // Update parent session cost
1125 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1126 return fantasy.ToolResponse{}, err
1127 }
1128
1129 return fantasy.NewTextResponse(result.Response.Content.Text()), nil
1130}
1131
1132// updateParentSessionCost accumulates the cost from a child session to its parent session.
1133func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
1134 childSession, err := c.sessions.Get(ctx, childSessionID)
1135 if err != nil {
1136 return fmt.Errorf("get child session: %w", err)
1137 }
1138
1139 parentSession, err := c.sessions.Get(ctx, parentSessionID)
1140 if err != nil {
1141 return fmt.Errorf("get parent session: %w", err)
1142 }
1143
1144 parentSession.Cost += childSession.Cost
1145
1146 if _, err := c.sessions.Save(ctx, parentSession); err != nil {
1147 return fmt.Errorf("save parent session: %w", err)
1148 }
1149
1150 return nil
1151}
1152
1153// discoverSkills is a thin fallback wrapper used only when no
1154// skills.Manager has been threaded through to the coordinator. All
1155// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
1156// run discovery in advance and pass the results via the manager;
1157// reaching this path means a caller bypassed both. It deliberately does
1158// NOT publish to the package-level broker — there are no subscribers in
1159// that case, so doing so would be misleading without delivering the
1160// snapshot anywhere useful.
1161func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1162 opts := cfg.Config().Options
1163 var paths, disabled []string
1164 if opts != nil {
1165 paths = opts.SkillsPaths
1166 disabled = opts.DisabledSkills
1167 }
1168 var resolver func(string) (string, error)
1169 if r := cfg.Resolver(); r != nil {
1170 resolver = r.ResolveValue
1171 }
1172 allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
1173 SkillsPaths: paths,
1174 DisabledSkills: disabled,
1175 Resolver: resolver,
1176 })
1177 logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
1178 return allSkills, activeSkills
1179}
1180
1181// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
1182// (if any) were loaded during this turn and which looked relevant based on
1183// a cheap keyword match against the user prompt. The goal is to surface
1184// "should-have-loaded but didn't" situations for later analysis.
1185//
1186// Logged at Info level under component=skills; heavy fields are elided when
1187// there is nothing interesting to report.
1188func logTurnSkillUsage(
1189 sessionID string,
1190 prompt string,
1191 activeSkills []*skills.Skill,
1192 tracker *skills.Tracker,
1193 before []string,
1194) {
1195 if tracker == nil || len(activeSkills) == 0 {
1196 return
1197 }
1198
1199 after := tracker.LoadedNames()
1200
1201 beforeSet := make(map[string]bool, len(before))
1202 for _, n := range before {
1203 beforeSet[n] = true
1204 }
1205 var loadedThisTurn []string
1206 for _, n := range after {
1207 if !beforeSet[n] {
1208 loadedThisTurn = append(loadedThisTurn, n)
1209 }
1210 }
1211
1212 slog.Info(
1213 "Skill turn summary",
1214 "component", "skills",
1215 "session_id", sessionID,
1216 "prompt_len", len(prompt),
1217 "active_total", len(activeSkills),
1218 "loaded_total", len(after),
1219 "loaded_this_turn", loadedThisTurn,
1220 )
1221}
1222
1223// logDiscoveryStats emits a single structured log line summarising skill
1224// discovery for the current session. It is intentionally low-volume: one
1225// line per session start. Builtin vs user counts are derived from the
1226// SkillState.Path — builtin states use the "builtin/" embed prefix.
1227func logDiscoveryStats(
1228 states []*skills.SkillState,
1229 userPaths []string,
1230 allSkills, activeSkills []*skills.Skill,
1231 disabled []string,
1232) {
1233 var builtinOK, builtinErr, userOK, userErr int
1234 for _, s := range states {
1235 isBuiltin := strings.HasPrefix(s.Path, "builtin/")
1236 switch {
1237 case isBuiltin && s.State == skills.StateNormal:
1238 builtinOK++
1239 case isBuiltin && s.State == skills.StateError:
1240 builtinErr++
1241 case !isBuiltin && s.State == skills.StateNormal:
1242 userOK++
1243 case !isBuiltin && s.State == skills.StateError:
1244 userErr++
1245 }
1246 }
1247
1248 activeNames := make([]string, 0, len(activeSkills))
1249 for _, s := range activeSkills {
1250 activeNames = append(activeNames, s.Name)
1251 }
1252
1253 xml := skills.ToPromptXML(activeSkills)
1254
1255 slog.Info(
1256 "Skill discovery complete",
1257 "component", "skills",
1258 "builtin_ok", builtinOK,
1259 "builtin_errors", builtinErr,
1260 "user_ok", userOK,
1261 "user_errors", userErr,
1262 "user_paths", len(userPaths),
1263 "deduped_total", len(allSkills),
1264 "active", len(activeSkills),
1265 "disabled", len(disabled),
1266 "prompt_bytes", len(xml),
1267 "prompt_tok_est", skills.ApproxTokenCount(xml),
1268 "active_names", activeNames,
1269 )
1270}