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