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 "os"
14 "slices"
15 "strings"
16
17 "charm.land/fantasy"
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/crush/internal/agent/prompt"
20 "github.com/charmbracelet/crush/internal/agent/tools"
21 "github.com/charmbracelet/crush/internal/config"
22 "github.com/charmbracelet/crush/internal/csync"
23 "github.com/charmbracelet/crush/internal/history"
24 "github.com/charmbracelet/crush/internal/log"
25 "github.com/charmbracelet/crush/internal/lsp"
26 "github.com/charmbracelet/crush/internal/message"
27 "github.com/charmbracelet/crush/internal/permission"
28 "github.com/charmbracelet/crush/internal/session"
29 "golang.org/x/sync/errgroup"
30
31 "charm.land/fantasy/providers/anthropic"
32 "charm.land/fantasy/providers/azure"
33 "charm.land/fantasy/providers/bedrock"
34 "charm.land/fantasy/providers/google"
35 "charm.land/fantasy/providers/openai"
36 "charm.land/fantasy/providers/openaicompat"
37 "charm.land/fantasy/providers/openrouter"
38 openaisdk "github.com/openai/openai-go/v2/option"
39 "github.com/qjebbs/go-jsons"
40)
41
42type Coordinator interface {
43 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
44 // SetMainAgent(string)
45 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
46 Cancel(sessionID string)
47 CancelAll()
48 IsSessionBusy(sessionID string) bool
49 IsBusy() bool
50 QueuedPrompts(sessionID string) int
51 ClearQueue(sessionID string)
52 Summarize(context.Context, string) error
53 Model() Model
54 UpdateModels(ctx context.Context) error
55 RefreshTools(ctx context.Context) error
56}
57
58type coordinator struct {
59 cfg *config.Config
60 sessions session.Service
61 messages message.Service
62 permissions permission.Service
63 history history.Service
64 lspClients *csync.Map[string, *lsp.Client]
65
66 currentAgent SessionAgent
67 agents map[string]SessionAgent
68
69 readyWg errgroup.Group
70}
71
72func NewCoordinator(
73 ctx context.Context,
74 cfg *config.Config,
75 sessions session.Service,
76 messages message.Service,
77 permissions permission.Service,
78 history history.Service,
79 lspClients *csync.Map[string, *lsp.Client],
80) (Coordinator, error) {
81 c := &coordinator{
82 cfg: cfg,
83 sessions: sessions,
84 messages: messages,
85 permissions: permissions,
86 history: history,
87 lspClients: lspClients,
88 agents: make(map[string]SessionAgent),
89 }
90
91 agentCfg, ok := cfg.Agents[config.AgentCoder]
92 if !ok {
93 return nil, errors.New("coder agent not configured")
94 }
95
96 // TODO: make this dynamic when we support multiple agents
97 prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
98 if err != nil {
99 return nil, err
100 }
101
102 agent, err := c.buildAgent(ctx, prompt, agentCfg)
103 if err != nil {
104 return nil, err
105 }
106 c.currentAgent = agent
107 c.agents[config.AgentCoder] = agent
108 return c, nil
109}
110
111// Run implements Coordinator.
112func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
113 if err := c.readyWg.Wait(); err != nil {
114 return nil, err
115 }
116
117 model := c.currentAgent.Model()
118 maxTokens := model.CatwalkCfg.DefaultMaxTokens
119 if model.ModelCfg.MaxTokens != 0 {
120 maxTokens = model.ModelCfg.MaxTokens
121 }
122
123 if !model.CatwalkCfg.SupportsImages && attachments != nil {
124 attachments = nil
125 }
126
127 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
128 if !ok {
129 return nil, errors.New("model provider not configured")
130 }
131
132 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
133
134 return c.currentAgent.Run(ctx, SessionAgentCall{
135 SessionID: sessionID,
136 Prompt: prompt,
137 Attachments: attachments,
138 MaxOutputTokens: maxTokens,
139 ProviderOptions: mergedOptions,
140 Temperature: temp,
141 TopP: topP,
142 TopK: topK,
143 FrequencyPenalty: freqPenalty,
144 PresencePenalty: presPenalty,
145 })
146}
147
148func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
149 options := fantasy.ProviderOptions{}
150
151 cfgOpts := []byte("{}")
152 providerCfgOpts := []byte("{}")
153 catwalkOpts := []byte("{}")
154
155 if model.ModelCfg.ProviderOptions != nil {
156 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
157 if err == nil {
158 cfgOpts = data
159 }
160 }
161
162 if providerCfg.ProviderOptions != nil {
163 data, err := json.Marshal(providerCfg.ProviderOptions)
164 if err == nil {
165 providerCfgOpts = data
166 }
167 }
168
169 if model.CatwalkCfg.Options.ProviderOptions != nil {
170 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
171 if err == nil {
172 catwalkOpts = data
173 }
174 }
175
176 readers := []io.Reader{
177 bytes.NewReader(catwalkOpts),
178 bytes.NewReader(providerCfgOpts),
179 bytes.NewReader(cfgOpts),
180 }
181
182 got, err := jsons.Merge(readers)
183 if err != nil {
184 slog.Error("Could not merge call config", "err", err)
185 return options
186 }
187
188 mergedOptions := make(map[string]any)
189
190 err = json.Unmarshal([]byte(got), &mergedOptions)
191 if err != nil {
192 slog.Error("Could not create config for call", "err", err)
193 return options
194 }
195
196 switch providerCfg.Type {
197 case openai.Name, azure.Name:
198 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
199 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
200 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
201 }
202 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
203 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
204 mergedOptions["reasoning_summary"] = "auto"
205 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
206 }
207 parsed, err := openai.ParseResponsesOptions(mergedOptions)
208 if err == nil {
209 options[openai.Name] = parsed
210 }
211 } else {
212 parsed, err := openai.ParseOptions(mergedOptions)
213 if err == nil {
214 options[openai.Name] = parsed
215 }
216 }
217 case anthropic.Name:
218 _, hasThink := mergedOptions["thinking"]
219 if !hasThink && model.ModelCfg.Think {
220 mergedOptions["thinking"] = map[string]any{
221 // TODO: kujtim see if we need to make this dynamic
222 "budget_tokens": 2000,
223 }
224 }
225 parsed, err := anthropic.ParseOptions(mergedOptions)
226 if err == nil {
227 options[anthropic.Name] = parsed
228 }
229
230 case openrouter.Name:
231 _, hasReasoning := mergedOptions["reasoning"]
232 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
233 mergedOptions["reasoning"] = map[string]any{
234 "enabled": true,
235 "effort": model.ModelCfg.ReasoningEffort,
236 }
237 }
238 parsed, err := openrouter.ParseOptions(mergedOptions)
239 if err == nil {
240 options[openrouter.Name] = parsed
241 }
242 case google.Name:
243 _, hasReasoning := mergedOptions["thinking_config"]
244 if !hasReasoning {
245 mergedOptions["thinking_config"] = map[string]any{
246 "thinking_budget": 2000,
247 "include_thoughts": true,
248 }
249 }
250 parsed, err := google.ParseOptions(mergedOptions)
251 if err == nil {
252 options[google.Name] = parsed
253 }
254 case openaicompat.Name:
255 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
256 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
257 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
258 }
259 parsed, err := openaicompat.ParseOptions(mergedOptions)
260 if err == nil {
261 options[openaicompat.Name] = parsed
262 }
263 }
264
265 return options
266}
267
268func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
269 modelOptions := getProviderOptions(model, cfg)
270 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
271 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
272 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
273 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
274 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
275 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
276}
277
278func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
279 large, small, err := c.buildAgentModels(ctx)
280 if err != nil {
281 return nil, err
282 }
283
284 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
285 if err != nil {
286 return nil, err
287 }
288
289 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
290 result := NewSessionAgent(SessionAgentOptions{
291 large,
292 small,
293 largeProviderCfg.SystemPromptPrefix,
294 systemPrompt,
295 c.cfg.Options.DisableAutoSummarize,
296 c.permissions.SkipRequests(),
297 c.sessions,
298 c.messages,
299 nil,
300 })
301 c.readyWg.Go(func() error {
302 tools, err := c.buildTools(ctx, agent)
303 if err != nil {
304 return err
305 }
306 result.SetTools(tools)
307 return nil
308 })
309
310 return result, nil
311}
312
313func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
314 var allTools []fantasy.AgentTool
315 if slices.Contains(agent.AllowedTools, AgentToolName) {
316 agentTool, err := c.agentTool(ctx)
317 if err != nil {
318 return nil, err
319 }
320 allTools = append(allTools, agentTool)
321 }
322
323 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
324 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
325 if err != nil {
326 return nil, err
327 }
328 allTools = append(allTools, agenticFetchTool)
329 }
330
331 // Get the model name for the agent
332 modelName := ""
333 if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
334 if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
335 modelName = model.Name
336 }
337 }
338
339 allTools = append(allTools,
340 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
341 tools.NewJobOutputTool(),
342 tools.NewJobKillTool(),
343 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
344 tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
345 tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
346 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
347 tools.NewGlobTool(c.cfg.WorkingDir()),
348 tools.NewGrepTool(c.cfg.WorkingDir()),
349 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
350 tools.NewSourcegraphTool(nil),
351 tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
352 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
353 )
354
355 if len(c.cfg.LSP) > 0 {
356 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
357 }
358
359 var filteredTools []fantasy.AgentTool
360 for _, tool := range allTools {
361 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
362 filteredTools = append(filteredTools, tool)
363 }
364 }
365
366 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
367 if agent.AllowedMCP == nil {
368 // No MCP restrictions
369 filteredTools = append(filteredTools, tool)
370 continue
371 }
372 if len(agent.AllowedMCP) == 0 {
373 // No MCPs allowed
374 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
375 break
376 }
377
378 for mcp, tools := range agent.AllowedMCP {
379 if mcp != tool.MCP() {
380 continue
381 }
382 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
383 filteredTools = append(filteredTools, tool)
384 }
385 }
386 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
387 }
388 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
389 return strings.Compare(a.Info().Name, b.Info().Name)
390 })
391 return filteredTools, nil
392}
393
394// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
395func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
396 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
397 if !ok {
398 return Model{}, Model{}, errors.New("large model not selected")
399 }
400 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
401 if !ok {
402 return Model{}, Model{}, errors.New("small model not selected")
403 }
404
405 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
406 if !ok {
407 return Model{}, Model{}, errors.New("large model provider not configured")
408 }
409
410 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
411 if err != nil {
412 return Model{}, Model{}, err
413 }
414
415 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
416 if !ok {
417 return Model{}, Model{}, errors.New("large model provider not configured")
418 }
419
420 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
421 if err != nil {
422 return Model{}, Model{}, err
423 }
424
425 var largeCatwalkModel *catwalk.Model
426 var smallCatwalkModel *catwalk.Model
427
428 for _, m := range largeProviderCfg.Models {
429 if m.ID == largeModelCfg.Model {
430 largeCatwalkModel = &m
431 }
432 }
433 for _, m := range smallProviderCfg.Models {
434 if m.ID == smallModelCfg.Model {
435 smallCatwalkModel = &m
436 }
437 }
438
439 if largeCatwalkModel == nil {
440 return Model{}, Model{}, errors.New("large model not found in provider config")
441 }
442
443 if smallCatwalkModel == nil {
444 return Model{}, Model{}, errors.New("snall model not found in provider config")
445 }
446
447 largeModelID := largeModelCfg.Model
448 smallModelID := smallModelCfg.Model
449
450 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
451 largeModelID += ":exacto"
452 }
453
454 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
455 smallModelID += ":exacto"
456 }
457
458 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
459 if err != nil {
460 return Model{}, Model{}, err
461 }
462 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
463 if err != nil {
464 return Model{}, Model{}, err
465 }
466
467 return Model{
468 Model: largeModel,
469 CatwalkCfg: *largeCatwalkModel,
470 ModelCfg: largeModelCfg,
471 }, Model{
472 Model: smallModel,
473 CatwalkCfg: *smallCatwalkModel,
474 ModelCfg: smallModelCfg,
475 }, nil
476}
477
478func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
479 var opts []anthropic.Option
480
481 if strings.HasPrefix(apiKey, "Bearer ") {
482 // NOTE: Prevent the SDK from picking up the API key from env.
483 os.Setenv("ANTHROPIC_API_KEY", "")
484
485 headers["Authorization"] = apiKey
486 } else if apiKey != "" {
487 // X-Api-Key header
488 opts = append(opts, anthropic.WithAPIKey(apiKey))
489 }
490
491 if len(headers) > 0 {
492 opts = append(opts, anthropic.WithHeaders(headers))
493 }
494
495 if baseURL != "" {
496 opts = append(opts, anthropic.WithBaseURL(baseURL))
497 }
498
499 if c.cfg.Options.Debug {
500 httpClient := log.NewHTTPClient()
501 opts = append(opts, anthropic.WithHTTPClient(httpClient))
502 }
503
504 return anthropic.New(opts...)
505}
506
507func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
508 opts := []openai.Option{
509 openai.WithAPIKey(apiKey),
510 openai.WithUseResponsesAPI(),
511 }
512 if c.cfg.Options.Debug {
513 httpClient := log.NewHTTPClient()
514 opts = append(opts, openai.WithHTTPClient(httpClient))
515 }
516 if len(headers) > 0 {
517 opts = append(opts, openai.WithHeaders(headers))
518 }
519 if baseURL != "" {
520 opts = append(opts, openai.WithBaseURL(baseURL))
521 }
522 return openai.New(opts...)
523}
524
525func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
526 opts := []openrouter.Option{
527 openrouter.WithAPIKey(apiKey),
528 }
529 if c.cfg.Options.Debug {
530 httpClient := log.NewHTTPClient()
531 opts = append(opts, openrouter.WithHTTPClient(httpClient))
532 }
533 if len(headers) > 0 {
534 opts = append(opts, openrouter.WithHeaders(headers))
535 }
536 return openrouter.New(opts...)
537}
538
539func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
540 opts := []openaicompat.Option{
541 openaicompat.WithBaseURL(baseURL),
542 openaicompat.WithAPIKey(apiKey),
543 }
544 if c.cfg.Options.Debug {
545 httpClient := log.NewHTTPClient()
546 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
547 }
548 if len(headers) > 0 {
549 opts = append(opts, openaicompat.WithHeaders(headers))
550 }
551
552 for extraKey, extraValue := range extraBody {
553 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
554 }
555
556 return openaicompat.New(opts...)
557}
558
559func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
560 opts := []azure.Option{
561 azure.WithBaseURL(baseURL),
562 azure.WithAPIKey(apiKey),
563 azure.WithUseResponsesAPI(),
564 }
565 if c.cfg.Options.Debug {
566 httpClient := log.NewHTTPClient()
567 opts = append(opts, azure.WithHTTPClient(httpClient))
568 }
569 if options == nil {
570 options = make(map[string]string)
571 }
572 if apiVersion, ok := options["apiVersion"]; ok {
573 opts = append(opts, azure.WithAPIVersion(apiVersion))
574 }
575 if len(headers) > 0 {
576 opts = append(opts, azure.WithHeaders(headers))
577 }
578
579 return azure.New(opts...)
580}
581
582func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
583 var opts []bedrock.Option
584 if c.cfg.Options.Debug {
585 httpClient := log.NewHTTPClient()
586 opts = append(opts, bedrock.WithHTTPClient(httpClient))
587 }
588 if len(headers) > 0 {
589 opts = append(opts, bedrock.WithHeaders(headers))
590 }
591 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
592 if bearerToken != "" {
593 opts = append(opts, bedrock.WithAPIKey(bearerToken))
594 }
595 return bedrock.New(opts...)
596}
597
598func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
599 opts := []google.Option{
600 google.WithBaseURL(baseURL),
601 google.WithGeminiAPIKey(apiKey),
602 }
603 if c.cfg.Options.Debug {
604 httpClient := log.NewHTTPClient()
605 opts = append(opts, google.WithHTTPClient(httpClient))
606 }
607 if len(headers) > 0 {
608 opts = append(opts, google.WithHeaders(headers))
609 }
610 return google.New(opts...)
611}
612
613func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
614 opts := []google.Option{}
615 if c.cfg.Options.Debug {
616 httpClient := log.NewHTTPClient()
617 opts = append(opts, google.WithHTTPClient(httpClient))
618 }
619 if len(headers) > 0 {
620 opts = append(opts, google.WithHeaders(headers))
621 }
622
623 project := options["project"]
624 location := options["location"]
625
626 opts = append(opts, google.WithVertex(project, location))
627
628 return google.New(opts...)
629}
630
631func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
632 if model.Think {
633 return true
634 }
635
636 if model.ProviderOptions == nil {
637 return false
638 }
639
640 opts, err := anthropic.ParseOptions(model.ProviderOptions)
641 if err != nil {
642 return false
643 }
644 if opts.Thinking != nil {
645 return true
646 }
647 return false
648}
649
650func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
651 headers := maps.Clone(providerCfg.ExtraHeaders)
652 if headers == nil {
653 headers = make(map[string]string)
654 }
655
656 // handle special headers for anthropic
657 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
658 if v, ok := headers["anthropic-beta"]; ok {
659 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
660 } else {
661 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
662 }
663 }
664
665 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
666 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
667
668 switch providerCfg.Type {
669 case openai.Name:
670 return c.buildOpenaiProvider(baseURL, apiKey, headers)
671 case anthropic.Name:
672 return c.buildAnthropicProvider(baseURL, apiKey, headers)
673 case openrouter.Name:
674 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
675 case azure.Name:
676 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
677 case bedrock.Name:
678 return c.buildBedrockProvider(headers)
679 case google.Name:
680 return c.buildGoogleProvider(baseURL, apiKey, headers)
681 case "google-vertex":
682 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
683 case openaicompat.Name:
684 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
685 default:
686 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
687 }
688}
689
690func isExactoSupported(modelID string) bool {
691 supportedModels := []string{
692 "moonshotai/kimi-k2-0905",
693 "deepseek/deepseek-v3.1-terminus",
694 "z-ai/glm-4.6",
695 "openai/gpt-oss-120b",
696 "qwen/qwen3-coder",
697 }
698 return slices.Contains(supportedModels, modelID)
699}
700
701func (c *coordinator) Cancel(sessionID string) {
702 c.currentAgent.Cancel(sessionID)
703}
704
705func (c *coordinator) CancelAll() {
706 c.currentAgent.CancelAll()
707}
708
709func (c *coordinator) ClearQueue(sessionID string) {
710 c.currentAgent.ClearQueue(sessionID)
711}
712
713func (c *coordinator) IsBusy() bool {
714 return c.currentAgent.IsBusy()
715}
716
717func (c *coordinator) IsSessionBusy(sessionID string) bool {
718 return c.currentAgent.IsSessionBusy(sessionID)
719}
720
721func (c *coordinator) Model() Model {
722 return c.currentAgent.Model()
723}
724
725func (c *coordinator) UpdateModels(ctx context.Context) error {
726 // build the models again so we make sure we get the latest config
727 large, small, err := c.buildAgentModels(ctx)
728 if err != nil {
729 return err
730 }
731 c.currentAgent.SetModels(large, small)
732
733 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
734 if !ok {
735 return errors.New("coder agent not configured")
736 }
737
738 tools, err := c.buildTools(ctx, agentCfg)
739 if err != nil {
740 return err
741 }
742 c.currentAgent.SetTools(tools)
743 return nil
744}
745
746func (c *coordinator) RefreshTools(ctx context.Context) error {
747 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
748 if !ok {
749 return errors.New("coder agent not configured")
750 }
751
752 tools, err := c.buildTools(ctx, agentCfg)
753 if err != nil {
754 return err
755 }
756 c.currentAgent.SetTools(tools)
757 slog.Debug("refreshed agent tools", "count", len(tools))
758 return nil
759}
760
761func (c *coordinator) QueuedPrompts(sessionID string) int {
762 return c.currentAgent.QueuedPrompts(sessionID)
763}
764
765func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
766 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
767 if !ok {
768 return errors.New("model provider not configured")
769 }
770 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
771}