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