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