coordinator.go

  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}