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/home"
30 "github.com/charmbracelet/crush/internal/hooks"
31 "github.com/charmbracelet/crush/internal/log"
32 "github.com/charmbracelet/crush/internal/lsp"
33 "github.com/charmbracelet/crush/internal/message"
34 "github.com/charmbracelet/crush/internal/oauth/copilot"
35 "github.com/charmbracelet/crush/internal/permission"
36 "github.com/charmbracelet/crush/internal/pubsub"
37 "github.com/charmbracelet/crush/internal/session"
38 "github.com/charmbracelet/crush/internal/skills"
39 "golang.org/x/sync/errgroup"
40
41 "charm.land/fantasy/providers/anthropic"
42 "charm.land/fantasy/providers/azure"
43 "charm.land/fantasy/providers/bedrock"
44 "charm.land/fantasy/providers/google"
45 "charm.land/fantasy/providers/openai"
46 "charm.land/fantasy/providers/openaicompat"
47 "charm.land/fantasy/providers/openrouter"
48 "charm.land/fantasy/providers/vercel"
49 openaisdk "github.com/charmbracelet/openai-go/option"
50 "github.com/qjebbs/go-jsons"
51)
52
53// Coordinator errors.
54var (
55 errCoderAgentNotConfigured = errors.New("coder agent not configured")
56 errModelProviderNotConfigured = errors.New("model provider not configured")
57 errLargeModelNotSelected = errors.New("large model not selected")
58 errSmallModelNotSelected = errors.New("small model not selected")
59 errLargeModelProviderNotConfigured = errors.New("large model provider not configured")
60 errSmallModelProviderNotConfigured = errors.New("small model provider not configured")
61 errLargeModelNotFound = errors.New("large model not found in provider config")
62 errSmallModelNotFound = errors.New("small model not found in provider config")
63)
64
65type Coordinator interface {
66 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
67 // SetMainAgent(string)
68 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
69 Cancel(sessionID string)
70 CancelAll()
71 IsSessionBusy(sessionID string) bool
72 IsBusy() bool
73 QueuedPrompts(sessionID string) int
74 QueuedPromptsList(sessionID string) []string
75 ClearQueue(sessionID string)
76 Summarize(context.Context, string) error
77 Model() Model
78 UpdateModels(ctx context.Context) error
79}
80
81type coordinator struct {
82 cfg *config.ConfigStore
83 sessions session.Service
84 messages message.Service
85 permissions permission.Service
86 history history.Service
87 filetracker filetracker.Service
88 lspManager *lsp.Manager
89 notify pubsub.Publisher[notify.Notification]
90
91 currentAgent SessionAgent
92 agents map[string]SessionAgent
93
94 // Skills discovery results (session-start snapshot).
95 allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
96 activeSkills []*skills.Skill // Post-filter: active skills only.
97 skillTracker *skills.Tracker
98
99 readyWg errgroup.Group
100}
101
102func NewCoordinator(
103 ctx context.Context,
104 cfg *config.ConfigStore,
105 sessions session.Service,
106 messages message.Service,
107 permissions permission.Service,
108 history history.Service,
109 filetracker filetracker.Service,
110 lspManager *lsp.Manager,
111 notify pubsub.Publisher[notify.Notification],
112) (Coordinator, error) {
113 // Discover skills once at session start.
114 allSkills, activeSkills := discoverSkills(cfg)
115 skillTracker := skills.NewTracker(activeSkills)
116
117 c := &coordinator{
118 cfg: cfg,
119 sessions: sessions,
120 messages: messages,
121 permissions: permissions,
122 history: history,
123 filetracker: filetracker,
124 lspManager: lspManager,
125 notify: notify,
126 agents: make(map[string]SessionAgent),
127 allSkills: allSkills,
128 activeSkills: activeSkills,
129 skillTracker: skillTracker,
130 }
131
132 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
133 if !ok {
134 return nil, errCoderAgentNotConfigured
135 }
136
137 // TODO: make this dynamic when we support multiple agents
138 prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
139 if err != nil {
140 return nil, err
141 }
142
143 agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
144 if err != nil {
145 return nil, err
146 }
147 c.currentAgent = agent
148 c.agents[config.AgentCoder] = agent
149 return c, nil
150}
151
152// Run implements Coordinator.
153func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
154 if err := c.readyWg.Wait(); err != nil {
155 return nil, err
156 }
157
158 // refresh models before each run
159 if err := c.UpdateModels(ctx); err != nil {
160 return nil, fmt.Errorf("failed to update models: %w", err)
161 }
162
163 model := c.currentAgent.Model()
164 maxTokens := model.CatwalkCfg.DefaultMaxTokens
165 if model.ModelCfg.MaxTokens != 0 {
166 maxTokens = model.ModelCfg.MaxTokens
167 }
168
169 if !model.CatwalkCfg.SupportsImages && attachments != nil {
170 // filter out image attachments
171 filteredAttachments := make([]message.Attachment, 0, len(attachments))
172 for _, att := range attachments {
173 if att.IsText() {
174 filteredAttachments = append(filteredAttachments, att)
175 }
176 }
177 attachments = filteredAttachments
178 }
179
180 providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
181 if !ok {
182 return nil, errModelProviderNotConfigured
183 }
184
185 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
186
187 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
188 slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
189 if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
190 // NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
191 // depends on the flow below. If refresh fails, proceed with the token we have.
192 slog.Error("Failed to refresh OAuth2 token. Proceeding with existing token.", "error", err)
193 }
194 }
195
196 run := func() (*fantasy.AgentResult, error) {
197 return c.currentAgent.Run(ctx, SessionAgentCall{
198 SessionID: sessionID,
199 Prompt: prompt,
200 Attachments: attachments,
201 MaxOutputTokens: maxTokens,
202 ProviderOptions: mergedOptions,
203 Temperature: temp,
204 TopP: topP,
205 TopK: topK,
206 FrequencyPenalty: freqPenalty,
207 PresencePenalty: presPenalty,
208 })
209 }
210 beforeLoaded := c.skillTracker.LoadedNames()
211 result, originalErr := run()
212 logTurnSkillUsage(sessionID, prompt, c.activeSkills, c.skillTracker, beforeLoaded)
213
214 if c.isUnauthorized(originalErr) {
215 switch {
216 case providerCfg.OAuthToken != nil:
217 slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
218 if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
219 return nil, originalErr
220 }
221 slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
222 return run()
223 case strings.Contains(providerCfg.APIKeyTemplate, "$"):
224 slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
225 if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
226 return nil, originalErr
227 }
228 slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
229 return run()
230 }
231 }
232
233 return result, originalErr
234}
235
236func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
237 options := fantasy.ProviderOptions{}
238
239 cfgOpts := []byte("{}")
240 providerCfgOpts := []byte("{}")
241 catwalkOpts := []byte("{}")
242
243 if model.ModelCfg.ProviderOptions != nil {
244 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
245 if err == nil {
246 cfgOpts = data
247 }
248 }
249
250 if providerCfg.ProviderOptions != nil {
251 data, err := json.Marshal(providerCfg.ProviderOptions)
252 if err == nil {
253 providerCfgOpts = data
254 }
255 }
256
257 if model.CatwalkCfg.Options.ProviderOptions != nil {
258 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
259 if err == nil {
260 catwalkOpts = data
261 }
262 }
263
264 readers := []io.Reader{
265 bytes.NewReader(catwalkOpts),
266 bytes.NewReader(providerCfgOpts),
267 bytes.NewReader(cfgOpts),
268 }
269
270 got, err := jsons.Merge(readers)
271 if err != nil {
272 slog.Error("Could not merge call config", "err", err)
273 return options
274 }
275
276 mergedOptions := make(map[string]any)
277
278 err = json.Unmarshal([]byte(got), &mergedOptions)
279 if err != nil {
280 slog.Error("Could not create config for call", "err", err)
281 return options
282 }
283
284 switch providerCfg.Type {
285 case openai.Name, azure.Name:
286 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
287 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
288 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
289 }
290 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
291 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
292 mergedOptions["reasoning_summary"] = "auto"
293 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
294 }
295 parsed, err := openai.ParseResponsesOptions(mergedOptions)
296 if err == nil {
297 options[openai.Name] = parsed
298 }
299 } else {
300 parsed, err := openai.ParseOptions(mergedOptions)
301 if err == nil {
302 options[openai.Name] = parsed
303 }
304 }
305 case anthropic.Name:
306 var (
307 _, hasEffort = mergedOptions["effort"]
308 _, hasThink = mergedOptions["thinking"]
309 )
310 switch {
311 case !hasEffort && model.ModelCfg.ReasoningEffort != "":
312 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
313 case !hasThink && model.ModelCfg.Think:
314 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
315 }
316 parsed, err := anthropic.ParseOptions(mergedOptions)
317 if err == nil {
318 options[anthropic.Name] = parsed
319 }
320
321 case openrouter.Name:
322 _, hasReasoning := mergedOptions["reasoning"]
323 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
324 mergedOptions["reasoning"] = map[string]any{
325 "enabled": true,
326 "effort": model.ModelCfg.ReasoningEffort,
327 }
328 }
329 parsed, err := openrouter.ParseOptions(mergedOptions)
330 if err == nil {
331 options[openrouter.Name] = parsed
332 }
333 case vercel.Name:
334 _, hasReasoning := mergedOptions["reasoning"]
335 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
336 mergedOptions["reasoning"] = map[string]any{
337 "enabled": true,
338 "effort": model.ModelCfg.ReasoningEffort,
339 }
340 }
341 parsed, err := vercel.ParseOptions(mergedOptions)
342 if err == nil {
343 options[vercel.Name] = parsed
344 }
345 case google.Name:
346 _, hasReasoning := mergedOptions["thinking_config"]
347 if !hasReasoning {
348 if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
349 mergedOptions["thinking_config"] = map[string]any{
350 "thinking_budget": 2000,
351 "include_thoughts": true,
352 }
353 } else {
354 mergedOptions["thinking_config"] = map[string]any{
355 "thinking_level": model.ModelCfg.ReasoningEffort,
356 "include_thoughts": true,
357 }
358 }
359 }
360 parsed, err := google.ParseOptions(mergedOptions)
361 if err == nil {
362 options[google.Name] = parsed
363 }
364 case openaicompat.Name:
365 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
366 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
367 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
368 }
369 parsed, err := openaicompat.ParseOptions(mergedOptions)
370 if err == nil {
371 options[openaicompat.Name] = parsed
372 }
373 }
374
375 return options
376}
377
378func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
379 modelOptions := getProviderOptions(model, cfg)
380 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
381 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
382 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
383 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
384 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
385 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
386}
387
388func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
389 large, small, err := c.buildAgentModels(ctx, isSubAgent)
390 if err != nil {
391 return nil, err
392 }
393
394 largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
395 result := NewSessionAgent(SessionAgentOptions{
396 LargeModel: large,
397 SmallModel: small,
398 SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
399 SystemPrompt: "",
400 IsSubAgent: isSubAgent,
401 DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
402 IsYolo: c.permissions.SkipRequests(),
403 Sessions: c.sessions,
404 Messages: c.messages,
405 Tools: nil,
406 Notify: c.notify,
407 })
408
409 c.readyWg.Go(func() error {
410 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg)
411 if err != nil {
412 return err
413 }
414 result.SetSystemPrompt(systemPrompt)
415 return nil
416 })
417
418 c.readyWg.Go(func() error {
419 tools, err := c.buildTools(ctx, agent, isSubAgent)
420 if err != nil {
421 return err
422 }
423 result.SetTools(tools)
424 return nil
425 })
426
427 return result, nil
428}
429
430func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubAgent bool) ([]fantasy.AgentTool, error) {
431 var allTools []fantasy.AgentTool
432 if slices.Contains(agent.AllowedTools, AgentToolName) {
433 agentTool, err := c.agentTool(ctx)
434 if err != nil {
435 return nil, err
436 }
437 allTools = append(allTools, agentTool)
438 }
439
440 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
441 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
442 if err != nil {
443 return nil, err
444 }
445 allTools = append(allTools, agenticFetchTool)
446 }
447
448 // Get the model name for the agent
449 modelName := ""
450 if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
451 if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
452 modelName = model.Name
453 }
454 }
455
456 logFile := filepath.Join(c.cfg.Config().Options.DataDirectory, "logs", "crush.log")
457
458 // Build hook runner if PreToolUse hooks are configured.
459 var hookRunner *hooks.Runner
460 if preToolHooks := c.cfg.Config().Hooks[hooks.EventPreToolUse]; len(preToolHooks) > 0 {
461 hookRunner = hooks.NewRunner(preToolHooks, c.cfg.WorkingDir(), c.cfg.WorkingDir())
462 }
463
464 allTools = append(allTools,
465 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
466 tools.NewCrushInfoTool(c.cfg, c.lspManager, c.allSkills, c.activeSkills, c.skillTracker),
467 tools.NewCrushLogsTool(logFile),
468 tools.NewJobOutputTool(),
469 tools.NewJobKillTool(),
470 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
471 tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
472 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
473 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
474 tools.NewGlobTool(c.cfg.WorkingDir()),
475 tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
476 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
477 tools.NewSourcegraphTool(nil),
478 tools.NewTodosTool(c.sessions),
479 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
480 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
481 )
482
483 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
484 if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
485 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
486 }
487
488 if len(c.cfg.Config().MCP) > 0 {
489 allTools = append(
490 allTools,
491 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
492 tools.NewReadMCPResourceTool(c.cfg, c.permissions),
493 )
494 }
495
496 var filteredTools []fantasy.AgentTool
497 for _, tool := range allTools {
498 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
499 filteredTools = append(filteredTools, tool)
500 }
501 }
502
503 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
504 if agent.AllowedMCP == nil {
505 // No MCP restrictions
506 filteredTools = append(filteredTools, tool)
507 continue
508 }
509 if len(agent.AllowedMCP) == 0 {
510 // No MCPs allowed
511 slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
512 break
513 }
514
515 for mcp, tools := range agent.AllowedMCP {
516 if mcp != tool.MCP() {
517 continue
518 }
519 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
520 filteredTools = append(filteredTools, tool)
521 break
522 }
523 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
524 }
525 }
526 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
527 return strings.Compare(a.Info().Name, b.Info().Name)
528 })
529
530 // Wrap tools with hook interception for the top-level agent only.
531 // Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run
532 // without hook interception to avoid firing the user's hook N times
533 // per delegated turn. The top-level invocation of the sub-agent tool
534 // itself is still wrapped from the coder's side.
535 filteredTools = wrapToolsWithHooks(filteredTools, hookRunner, isSubAgent)
536
537 return filteredTools, nil
538}
539
540// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
541func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
542 largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
543 if !ok {
544 return Model{}, Model{}, errLargeModelNotSelected
545 }
546 smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
547 if !ok {
548 return Model{}, Model{}, errSmallModelNotSelected
549 }
550
551 largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
552 if !ok {
553 return Model{}, Model{}, errLargeModelProviderNotConfigured
554 }
555
556 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
557 if err != nil {
558 return Model{}, Model{}, err
559 }
560
561 smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
562 if !ok {
563 return Model{}, Model{}, errSmallModelProviderNotConfigured
564 }
565
566 smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
567 if err != nil {
568 return Model{}, Model{}, err
569 }
570
571 var largeCatwalkModel *catwalk.Model
572 var smallCatwalkModel *catwalk.Model
573
574 for _, m := range largeProviderCfg.Models {
575 if m.ID == largeModelCfg.Model {
576 largeCatwalkModel = &m
577 }
578 }
579 for _, m := range smallProviderCfg.Models {
580 if m.ID == smallModelCfg.Model {
581 smallCatwalkModel = &m
582 }
583 }
584
585 if largeCatwalkModel == nil {
586 return Model{}, Model{}, errLargeModelNotFound
587 }
588
589 if smallCatwalkModel == nil {
590 return Model{}, Model{}, errSmallModelNotFound
591 }
592
593 largeModelID := largeModelCfg.Model
594 smallModelID := smallModelCfg.Model
595
596 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
597 largeModelID += ":exacto"
598 }
599
600 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
601 smallModelID += ":exacto"
602 }
603
604 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
605 if err != nil {
606 return Model{}, Model{}, err
607 }
608 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
609 if err != nil {
610 return Model{}, Model{}, err
611 }
612
613 return Model{
614 Model: largeModel,
615 CatwalkCfg: *largeCatwalkModel,
616 ModelCfg: largeModelCfg,
617 }, Model{
618 Model: smallModel,
619 CatwalkCfg: *smallCatwalkModel,
620 ModelCfg: smallModelCfg,
621 }, nil
622}
623
624func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
625 var opts []anthropic.Option
626
627 switch {
628 case strings.HasPrefix(apiKey, "Bearer "):
629 // NOTE: Prevent the SDK from picking up the API key from env.
630 os.Setenv("ANTHROPIC_API_KEY", "")
631 headers["Authorization"] = apiKey
632 case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina):
633 // NOTE: Prevent the SDK from picking up the API key from env.
634 os.Setenv("ANTHROPIC_API_KEY", "")
635 headers["Authorization"] = "Bearer " + apiKey
636 case apiKey != "":
637 // X-Api-Key header
638 opts = append(opts, anthropic.WithAPIKey(apiKey))
639 }
640
641 if len(headers) > 0 {
642 opts = append(opts, anthropic.WithHeaders(headers))
643 }
644
645 if baseURL != "" {
646 opts = append(opts, anthropic.WithBaseURL(baseURL))
647 }
648
649 if c.cfg.Config().Options.Debug {
650 httpClient := log.NewHTTPClient()
651 opts = append(opts, anthropic.WithHTTPClient(httpClient))
652 }
653 return anthropic.New(opts...)
654}
655
656func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
657 opts := []openai.Option{
658 openai.WithAPIKey(apiKey),
659 openai.WithUseResponsesAPI(),
660 }
661 if c.cfg.Config().Options.Debug {
662 httpClient := log.NewHTTPClient()
663 opts = append(opts, openai.WithHTTPClient(httpClient))
664 }
665 if len(headers) > 0 {
666 opts = append(opts, openai.WithHeaders(headers))
667 }
668 if baseURL != "" {
669 opts = append(opts, openai.WithBaseURL(baseURL))
670 }
671 return openai.New(opts...)
672}
673
674func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
675 opts := []openrouter.Option{
676 openrouter.WithAPIKey(apiKey),
677 }
678 if c.cfg.Config().Options.Debug {
679 httpClient := log.NewHTTPClient()
680 opts = append(opts, openrouter.WithHTTPClient(httpClient))
681 }
682 if len(headers) > 0 {
683 opts = append(opts, openrouter.WithHeaders(headers))
684 }
685 return openrouter.New(opts...)
686}
687
688func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
689 opts := []vercel.Option{
690 vercel.WithAPIKey(apiKey),
691 }
692 if c.cfg.Config().Options.Debug {
693 httpClient := log.NewHTTPClient()
694 opts = append(opts, vercel.WithHTTPClient(httpClient))
695 }
696 if len(headers) > 0 {
697 opts = append(opts, vercel.WithHeaders(headers))
698 }
699 return vercel.New(opts...)
700}
701
702func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
703 opts := []openaicompat.Option{
704 openaicompat.WithBaseURL(baseURL),
705 openaicompat.WithAPIKey(apiKey),
706 }
707
708 // Set HTTP client based on provider and debug mode.
709 var httpClient *http.Client
710 if providerID == string(catwalk.InferenceProviderCopilot) {
711 opts = append(opts, openaicompat.WithUseResponsesAPI())
712 httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
713 } else if c.cfg.Config().Options.Debug {
714 httpClient = log.NewHTTPClient()
715 }
716 if httpClient != nil {
717 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
718 }
719
720 if len(headers) > 0 {
721 opts = append(opts, openaicompat.WithHeaders(headers))
722 }
723
724 for extraKey, extraValue := range extraBody {
725 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
726 }
727
728 return openaicompat.New(opts...)
729}
730
731func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
732 opts := []azure.Option{
733 azure.WithBaseURL(baseURL),
734 azure.WithAPIKey(apiKey),
735 azure.WithUseResponsesAPI(),
736 }
737 if c.cfg.Config().Options.Debug {
738 httpClient := log.NewHTTPClient()
739 opts = append(opts, azure.WithHTTPClient(httpClient))
740 }
741 if options == nil {
742 options = make(map[string]string)
743 }
744 if apiVersion, ok := options["apiVersion"]; ok {
745 opts = append(opts, azure.WithAPIVersion(apiVersion))
746 }
747 if len(headers) > 0 {
748 opts = append(opts, azure.WithHeaders(headers))
749 }
750
751 return azure.New(opts...)
752}
753
754func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string) (fantasy.Provider, error) {
755 var opts []bedrock.Option
756 if c.cfg.Config().Options.Debug {
757 httpClient := log.NewHTTPClient()
758 opts = append(opts, bedrock.WithHTTPClient(httpClient))
759 }
760 if len(headers) > 0 {
761 opts = append(opts, bedrock.WithHeaders(headers))
762 }
763 switch {
764 case apiKey != "":
765 opts = append(opts, bedrock.WithAPIKey(apiKey))
766 case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
767 opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
768 default:
769 // Skip, let the SDK do authentication.
770 }
771 return bedrock.New(opts...)
772}
773
774func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
775 opts := []google.Option{
776 google.WithBaseURL(baseURL),
777 google.WithGeminiAPIKey(apiKey),
778 }
779 if c.cfg.Config().Options.Debug {
780 httpClient := log.NewHTTPClient()
781 opts = append(opts, google.WithHTTPClient(httpClient))
782 }
783 if len(headers) > 0 {
784 opts = append(opts, google.WithHeaders(headers))
785 }
786 return google.New(opts...)
787}
788
789func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
790 opts := []google.Option{}
791 if c.cfg.Config().Options.Debug {
792 httpClient := log.NewHTTPClient()
793 opts = append(opts, google.WithHTTPClient(httpClient))
794 }
795 if len(headers) > 0 {
796 opts = append(opts, google.WithHeaders(headers))
797 }
798
799 project := options["project"]
800 location := options["location"]
801
802 opts = append(opts, google.WithVertex(project, location))
803
804 return google.New(opts...)
805}
806
807func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
808 if model.Think {
809 return true
810 }
811 opts, err := anthropic.ParseOptions(model.ProviderOptions)
812 return err == nil && opts.Thinking != nil
813}
814
815func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
816 headers := maps.Clone(providerCfg.ExtraHeaders)
817 if headers == nil {
818 headers = make(map[string]string)
819 }
820
821 // handle special headers for anthropic
822 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
823 if v, ok := headers["anthropic-beta"]; ok {
824 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
825 } else {
826 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
827 }
828 }
829
830 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
831 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
832
833 switch providerCfg.Type {
834 case openai.Name:
835 return c.buildOpenaiProvider(baseURL, apiKey, headers)
836 case anthropic.Name:
837 return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
838 case openrouter.Name:
839 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
840 case vercel.Name:
841 return c.buildVercelProvider(baseURL, apiKey, headers)
842 case azure.Name:
843 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
844 case bedrock.Name:
845 return c.buildBedrockProvider(apiKey, headers)
846 case google.Name:
847 return c.buildGoogleProvider(baseURL, apiKey, headers)
848 case "google-vertex":
849 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
850 case openaicompat.Name, hyper.Name:
851 switch providerCfg.ID {
852 case hyper.Name:
853 baseURL = hyper.BaseURL() + "/v1"
854 headers["x-crush-id"] = event.GetID()
855 case string(catwalk.InferenceProviderZAI):
856 if providerCfg.ExtraBody == nil {
857 providerCfg.ExtraBody = map[string]any{}
858 }
859 providerCfg.ExtraBody["tool_stream"] = true
860 }
861 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
862 default:
863 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
864 }
865}
866
867func isExactoSupported(modelID string) bool {
868 supportedModels := []string{
869 "moonshotai/kimi-k2-0905",
870 "deepseek/deepseek-v3.1-terminus",
871 "z-ai/glm-4.6",
872 "openai/gpt-oss-120b",
873 "qwen/qwen3-coder",
874 }
875 return slices.Contains(supportedModels, modelID)
876}
877
878func (c *coordinator) Cancel(sessionID string) {
879 c.currentAgent.Cancel(sessionID)
880}
881
882func (c *coordinator) CancelAll() {
883 c.currentAgent.CancelAll()
884}
885
886func (c *coordinator) ClearQueue(sessionID string) {
887 c.currentAgent.ClearQueue(sessionID)
888}
889
890func (c *coordinator) IsBusy() bool {
891 return c.currentAgent.IsBusy()
892}
893
894func (c *coordinator) IsSessionBusy(sessionID string) bool {
895 return c.currentAgent.IsSessionBusy(sessionID)
896}
897
898func (c *coordinator) Model() Model {
899 return c.currentAgent.Model()
900}
901
902func (c *coordinator) UpdateModels(ctx context.Context) error {
903 // build the models again so we make sure we get the latest config
904 large, small, err := c.buildAgentModels(ctx, false)
905 if err != nil {
906 return err
907 }
908 c.currentAgent.SetModels(large, small)
909
910 agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
911 if !ok {
912 return errCoderAgentNotConfigured
913 }
914
915 tools, err := c.buildTools(ctx, agentCfg, false)
916 if err != nil {
917 return err
918 }
919 c.currentAgent.SetTools(tools)
920 return nil
921}
922
923func (c *coordinator) QueuedPrompts(sessionID string) int {
924 return c.currentAgent.QueuedPrompts(sessionID)
925}
926
927func (c *coordinator) QueuedPromptsList(sessionID string) []string {
928 return c.currentAgent.QueuedPromptsList(sessionID)
929}
930
931func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
932 providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
933 if !ok {
934 return errModelProviderNotConfigured
935 }
936 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
937}
938
939func (c *coordinator) isUnauthorized(err error) bool {
940 var providerErr *fantasy.ProviderError
941 return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
942}
943
944func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
945 if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
946 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
947 return err
948 }
949 if err := c.UpdateModels(ctx); err != nil {
950 return err
951 }
952 return nil
953}
954
955func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
956 newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
957 if err != nil {
958 slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
959 return err
960 }
961
962 providerCfg.APIKey = newAPIKey
963 c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
964
965 if err := c.UpdateModels(ctx); err != nil {
966 return err
967 }
968 return nil
969}
970
971// subAgentParams holds the parameters for running a sub-agent.
972type subAgentParams struct {
973 Agent SessionAgent
974 SessionID string
975 AgentMessageID string
976 ToolCallID string
977 Prompt string
978 SessionTitle string
979 // SessionSetup is an optional callback invoked after session creation
980 // but before agent execution, for custom session configuration.
981 SessionSetup func(sessionID string)
982}
983
984// runSubAgent runs a sub-agent and handles session management and cost accumulation.
985// It creates a sub-session, runs the agent with the given prompt, and propagates
986// the cost to the parent session.
987func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
988 // Create sub-session
989 agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
990 session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
991 if err != nil {
992 return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
993 }
994
995 // Call session setup function if provided
996 if params.SessionSetup != nil {
997 params.SessionSetup(session.ID)
998 }
999
1000 // Get model configuration
1001 model := params.Agent.Model()
1002 maxTokens := model.CatwalkCfg.DefaultMaxTokens
1003 if model.ModelCfg.MaxTokens != 0 {
1004 maxTokens = model.ModelCfg.MaxTokens
1005 }
1006
1007 providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
1008 if !ok {
1009 return fantasy.ToolResponse{}, errModelProviderNotConfigured
1010 }
1011
1012 // Run the agent
1013 result, err := params.Agent.Run(ctx, SessionAgentCall{
1014 SessionID: session.ID,
1015 Prompt: params.Prompt,
1016 MaxOutputTokens: maxTokens,
1017 ProviderOptions: getProviderOptions(model, providerCfg),
1018 Temperature: model.ModelCfg.Temperature,
1019 TopP: model.ModelCfg.TopP,
1020 TopK: model.ModelCfg.TopK,
1021 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1022 PresencePenalty: model.ModelCfg.PresencePenalty,
1023 NonInteractive: true,
1024 })
1025 if err != nil {
1026 return fantasy.NewTextErrorResponse("error generating response"), nil
1027 }
1028
1029 // Update parent session cost
1030 if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1031 return fantasy.ToolResponse{}, err
1032 }
1033
1034 return fantasy.NewTextResponse(result.Response.Content.Text()), nil
1035}
1036
1037// updateParentSessionCost accumulates the cost from a child session to its parent session.
1038func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
1039 childSession, err := c.sessions.Get(ctx, childSessionID)
1040 if err != nil {
1041 return fmt.Errorf("get child session: %w", err)
1042 }
1043
1044 parentSession, err := c.sessions.Get(ctx, parentSessionID)
1045 if err != nil {
1046 return fmt.Errorf("get parent session: %w", err)
1047 }
1048
1049 parentSession.Cost += childSession.Cost
1050
1051 if _, err := c.sessions.Save(ctx, parentSession); err != nil {
1052 return fmt.Errorf("save parent session: %w", err)
1053 }
1054
1055 return nil
1056}
1057
1058// discoverSkills runs the skill discovery pipeline and returns both the
1059// pre-filter (all discovered, after dedup) and post-filter (active) lists.
1060// It also emits a single diagnostic log line summarising the outcome to
1061// help track skill-loading health over time.
1062func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1063 builtin, builtinStates := skills.DiscoverBuiltinWithStates()
1064 discovered := append([]*skills.Skill(nil), builtin...)
1065
1066 var userStates []*skills.SkillState
1067 var userPaths []string
1068
1069 opts := cfg.Config().Options
1070 if opts != nil && len(opts.SkillsPaths) > 0 {
1071 userPaths = make([]string, 0, len(opts.SkillsPaths))
1072 for _, pth := range opts.SkillsPaths {
1073 expanded := home.Long(pth)
1074 if strings.HasPrefix(expanded, "$") {
1075 if resolved, err := cfg.Resolver().ResolveValue(expanded); err == nil {
1076 expanded = resolved
1077 }
1078 }
1079 userPaths = append(userPaths, expanded)
1080 }
1081 var userSkills []*skills.Skill
1082 userSkills, userStates = skills.DiscoverWithStates(userPaths)
1083 discovered = append(discovered, userSkills...)
1084 }
1085
1086 allSkills = skills.Deduplicate(discovered)
1087 var disabledSkills []string
1088 if opts != nil {
1089 disabledSkills = opts.DisabledSkills
1090 }
1091 activeSkills = skills.Filter(allSkills, disabledSkills)
1092
1093 logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1094 return allSkills, activeSkills
1095}
1096
1097// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
1098// (if any) were loaded during this turn and which looked relevant based on
1099// a cheap keyword match against the user prompt. The goal is to surface
1100// "should-have-loaded but didn't" situations for later analysis.
1101//
1102// Logged at Info level under component=skills; heavy fields are elided when
1103// there is nothing interesting to report.
1104func logTurnSkillUsage(
1105 sessionID string,
1106 prompt string,
1107 activeSkills []*skills.Skill,
1108 tracker *skills.Tracker,
1109 before []string,
1110) {
1111 if tracker == nil || len(activeSkills) == 0 {
1112 return
1113 }
1114
1115 after := tracker.LoadedNames()
1116
1117 beforeSet := make(map[string]bool, len(before))
1118 for _, n := range before {
1119 beforeSet[n] = true
1120 }
1121 var loadedThisTurn []string
1122 for _, n := range after {
1123 if !beforeSet[n] {
1124 loadedThisTurn = append(loadedThisTurn, n)
1125 }
1126 }
1127
1128 slog.Info("Skill turn summary",
1129 "component", "skills",
1130 "session_id", sessionID,
1131 "prompt_len", len(prompt),
1132 "active_total", len(activeSkills),
1133 "loaded_total", len(after),
1134 "loaded_this_turn", loadedThisTurn,
1135 )
1136}
1137
1138// logDiscoveryStats emits a single structured log line summarising skill
1139// discovery for the current session. It is intentionally low-volume: one
1140// line per session start.
1141func logDiscoveryStats(
1142 builtin []*skills.Skill,
1143 builtinStates, userStates []*skills.SkillState,
1144 userPaths []string,
1145 allSkills, activeSkills []*skills.Skill,
1146 disabled []string,
1147) {
1148 countErrors := func(states []*skills.SkillState) int {
1149 n := 0
1150 for _, s := range states {
1151 if s.State == skills.StateError {
1152 n++
1153 }
1154 }
1155 return n
1156 }
1157
1158 userOK := 0
1159 for _, s := range userStates {
1160 if s.State == skills.StateNormal {
1161 userOK++
1162 }
1163 }
1164
1165 activeNames := make([]string, 0, len(activeSkills))
1166 for _, s := range activeSkills {
1167 activeNames = append(activeNames, s.Name)
1168 }
1169
1170 xml := skills.ToPromptXML(activeSkills)
1171
1172 slog.Info("Skill discovery complete",
1173 "component", "skills",
1174 "builtin_ok", len(builtin),
1175 "builtin_errors", countErrors(builtinStates),
1176 "user_ok", userOK,
1177 "user_errors", countErrors(userStates),
1178 "user_paths", len(userPaths),
1179 "deduped_total", len(allSkills),
1180 "active", len(activeSkills),
1181 "disabled", len(disabled),
1182 "prompt_bytes", len(xml),
1183 "prompt_tok_est", skills.ApproxTokenCount(xml),
1184 "active_names", activeNames,
1185 )
1186}