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