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