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"
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"
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)
48 IsSessionBusy(sessionID string) bool
50 QueuedPrompts(sessionID string) int
51 ClearQueue(sessionID string)
52 Summarize(context.Context, string) error
54 UpdateModels(ctx context.Context) error
57type coordinator struct {
59 sessions session.Service
60 messages message.Service
61 permissions permission.Service
62 history history.Service
63 lspClients *csync.Map[string, *lsp.Client]
65 currentAgent SessionAgent
66 agents map[string]SessionAgent
68 readyWg errgroup.Group
74 sessions session.Service,
75 messages message.Service,
76 permissions permission.Service,
77 history history.Service,
78 lspClients *csync.Map[string, *lsp.Client],
79) (Coordinator, error) {
84 permissions: permissions,
86 lspClients: lspClients,
87 agents: make(map[string]SessionAgent),
90 agentCfg, ok := cfg.Agents[config.AgentCoder]
92 return nil, errors.New("coder agent not configured")
95 // TODO: make this dynamic when we support multiple agents
96 prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
101 agent, err := c.buildAgent(ctx, prompt, agentCfg)
105 c.currentAgent = agent
106 c.agents[config.AgentCoder] = agent
110// Run implements Coordinator.
111func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
112 if err := c.readyWg.Wait(); err != nil {
116 model := c.currentAgent.Model()
117 maxTokens := model.CatwalkCfg.DefaultMaxTokens
118 if model.ModelCfg.MaxTokens != 0 {
119 maxTokens = model.ModelCfg.MaxTokens
122 if !model.CatwalkCfg.SupportsImages && attachments != nil {
126 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
128 return nil, errors.New("model provider not configured")
131 mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
133 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
134 slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
135 if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
136 slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
137 return nil, refreshErr
140 // Rebuild models with refreshed token
141 if updateErr := c.UpdateModels(ctx); updateErr != nil {
142 slog.Error("Failed to update models after token refresh", "error", updateErr)
143 return nil, updateErr
146 result, err := c.currentAgent.Run(ctx, SessionAgentCall{
147 SessionID: sessionID,
149 Attachments: attachments,
150 MaxOutputTokens: maxTokens,
151 ProviderOptions: mergedOptions,
155 FrequencyPenalty: freqPenalty,
156 PresencePenalty: presPenalty,
161func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
162 options := fantasy.ProviderOptions{}
164 cfgOpts := []byte("{}")
165 providerCfgOpts := []byte("{}")
166 catwalkOpts := []byte("{}")
168 if model.ModelCfg.ProviderOptions != nil {
169 data, err := json.Marshal(model.ModelCfg.ProviderOptions)
175 if providerCfg.ProviderOptions != nil {
176 data, err := json.Marshal(providerCfg.ProviderOptions)
178 providerCfgOpts = data
182 if model.CatwalkCfg.Options.ProviderOptions != nil {
183 data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
189 readers := []io.Reader{
190 bytes.NewReader(catwalkOpts),
191 bytes.NewReader(providerCfgOpts),
192 bytes.NewReader(cfgOpts),
195 got, err := jsons.Merge(readers)
197 slog.Error("Could not merge call config", "err", err)
201 mergedOptions := make(map[string]any)
203 err = json.Unmarshal([]byte(got), &mergedOptions)
205 slog.Error("Could not create config for call", "err", err)
209 switch providerCfg.Type {
210 case openai.Name, azure.Name:
211 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
212 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
213 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
215 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
216 if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
217 mergedOptions["reasoning_summary"] = "auto"
218 mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
220 parsed, err := openai.ParseResponsesOptions(mergedOptions)
222 options[openai.Name] = parsed
225 parsed, err := openai.ParseOptions(mergedOptions)
227 options[openai.Name] = parsed
231 _, hasThink := mergedOptions["thinking"]
232 if !hasThink && model.ModelCfg.Think {
233 mergedOptions["thinking"] = map[string]any{
234 // TODO: kujtim see if we need to make this dynamic
235 "budget_tokens": 2000,
238 parsed, err := anthropic.ParseOptions(mergedOptions)
240 options[anthropic.Name] = parsed
243 case openrouter.Name:
244 _, hasReasoning := mergedOptions["reasoning"]
245 if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
246 mergedOptions["reasoning"] = map[string]any{
248 "effort": model.ModelCfg.ReasoningEffort,
251 parsed, err := openrouter.ParseOptions(mergedOptions)
253 options[openrouter.Name] = parsed
256 _, hasReasoning := mergedOptions["thinking_config"]
258 mergedOptions["thinking_config"] = map[string]any{
259 "thinking_budget": 2000,
260 "include_thoughts": true,
263 parsed, err := google.ParseOptions(mergedOptions)
265 options[google.Name] = parsed
267 case openaicompat.Name:
268 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
269 if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
270 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
272 parsed, err := openaicompat.ParseOptions(mergedOptions)
274 options[openaicompat.Name] = parsed
281func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
282 modelOptions := getProviderOptions(model, cfg)
283 temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
284 topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
285 topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
286 freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
287 presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
288 return modelOptions, temp, topP, topK, freqPenalty, presPenalty
291func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
292 large, small, err := c.buildAgentModels(ctx)
297 systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
302 largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
303 result := NewSessionAgent(SessionAgentOptions{
306 largeProviderCfg.SystemPromptPrefix,
308 c.cfg.Options.DisableAutoSummarize,
309 c.permissions.SkipRequests(),
314 c.readyWg.Go(func() error {
315 tools, err := c.buildTools(ctx, agent)
319 result.SetTools(tools)
326func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fantasy.AgentTool, error) {
327 var allTools []fantasy.AgentTool
328 if slices.Contains(agent.AllowedTools, AgentToolName) {
329 agentTool, err := c.agentTool(ctx)
333 allTools = append(allTools, agentTool)
336 if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
337 agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
341 allTools = append(allTools, agenticFetchTool)
344 // Get the model name for the agent
346 if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
347 if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
348 modelName = model.Name
352 allTools = append(allTools,
353 tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
354 tools.NewJobOutputTool(),
355 tools.NewJobKillTool(),
356 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
357 tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
358 tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
359 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
360 tools.NewGlobTool(c.cfg.WorkingDir()),
361 tools.NewGrepTool(c.cfg.WorkingDir()),
362 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
363 tools.NewSourcegraphTool(nil),
364 tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
365 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
368 if len(c.cfg.LSP) > 0 {
369 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
372 var filteredTools []fantasy.AgentTool
373 for _, tool := range allTools {
374 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
375 filteredTools = append(filteredTools, tool)
379 for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
380 if agent.AllowedMCP == nil {
381 // No MCP restrictions
382 filteredTools = append(filteredTools, tool)
385 if len(agent.AllowedMCP) == 0 {
387 slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
391 for mcp, tools := range agent.AllowedMCP {
392 if mcp != tool.MCP() {
395 if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
396 filteredTools = append(filteredTools, tool)
399 slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
401 slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
402 return strings.Compare(a.Info().Name, b.Info().Name)
404 return filteredTools, nil
407// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
408func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error) {
409 largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
411 return Model{}, Model{}, errors.New("large model not selected")
413 smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
415 return Model{}, Model{}, errors.New("small model not selected")
418 largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
420 return Model{}, Model{}, errors.New("large model provider not configured")
423 largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
425 return Model{}, Model{}, err
428 smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
430 return Model{}, Model{}, errors.New("large model provider not configured")
433 smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
435 return Model{}, Model{}, err
438 var largeCatwalkModel *catwalk.Model
439 var smallCatwalkModel *catwalk.Model
441 for _, m := range largeProviderCfg.Models {
442 if m.ID == largeModelCfg.Model {
443 largeCatwalkModel = &m
446 for _, m := range smallProviderCfg.Models {
447 if m.ID == smallModelCfg.Model {
448 smallCatwalkModel = &m
452 if largeCatwalkModel == nil {
453 return Model{}, Model{}, errors.New("large model not found in provider config")
456 if smallCatwalkModel == nil {
457 return Model{}, Model{}, errors.New("snall model not found in provider config")
460 largeModelID := largeModelCfg.Model
461 smallModelID := smallModelCfg.Model
463 if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
464 largeModelID += ":exacto"
467 if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
468 smallModelID += ":exacto"
471 largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
473 return Model{}, Model{}, err
475 smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
477 return Model{}, Model{}, err
482 CatwalkCfg: *largeCatwalkModel,
483 ModelCfg: largeModelCfg,
486 CatwalkCfg: *smallCatwalkModel,
487 ModelCfg: smallModelCfg,
491func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
492 var opts []anthropic.Option
494 if strings.HasPrefix(apiKey, "Bearer ") {
495 // NOTE: Prevent the SDK from picking up the API key from env.
496 os.Setenv("ANTHROPIC_API_KEY", "")
498 headers["Authorization"] = apiKey
499 } else if apiKey != "" {
501 opts = append(opts, anthropic.WithAPIKey(apiKey))
504 if len(headers) > 0 {
505 opts = append(opts, anthropic.WithHeaders(headers))
509 opts = append(opts, anthropic.WithBaseURL(baseURL))
512 if c.cfg.Options.Debug {
513 httpClient := log.NewHTTPClient()
514 opts = append(opts, anthropic.WithHTTPClient(httpClient))
517 return anthropic.New(opts...)
520func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
521 opts := []openai.Option{
522 openai.WithAPIKey(apiKey),
523 openai.WithUseResponsesAPI(),
525 if c.cfg.Options.Debug {
526 httpClient := log.NewHTTPClient()
527 opts = append(opts, openai.WithHTTPClient(httpClient))
529 if len(headers) > 0 {
530 opts = append(opts, openai.WithHeaders(headers))
533 opts = append(opts, openai.WithBaseURL(baseURL))
535 return openai.New(opts...)
538func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
539 opts := []openrouter.Option{
540 openrouter.WithAPIKey(apiKey),
542 if c.cfg.Options.Debug {
543 httpClient := log.NewHTTPClient()
544 opts = append(opts, openrouter.WithHTTPClient(httpClient))
546 if len(headers) > 0 {
547 opts = append(opts, openrouter.WithHeaders(headers))
549 return openrouter.New(opts...)
552func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any) (fantasy.Provider, error) {
553 opts := []openaicompat.Option{
554 openaicompat.WithBaseURL(baseURL),
555 openaicompat.WithAPIKey(apiKey),
557 if c.cfg.Options.Debug {
558 httpClient := log.NewHTTPClient()
559 opts = append(opts, openaicompat.WithHTTPClient(httpClient))
561 if len(headers) > 0 {
562 opts = append(opts, openaicompat.WithHeaders(headers))
565 for extraKey, extraValue := range extraBody {
566 opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
569 return openaicompat.New(opts...)
572func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
573 opts := []azure.Option{
574 azure.WithBaseURL(baseURL),
575 azure.WithAPIKey(apiKey),
576 azure.WithUseResponsesAPI(),
578 if c.cfg.Options.Debug {
579 httpClient := log.NewHTTPClient()
580 opts = append(opts, azure.WithHTTPClient(httpClient))
583 options = make(map[string]string)
585 if apiVersion, ok := options["apiVersion"]; ok {
586 opts = append(opts, azure.WithAPIVersion(apiVersion))
588 if len(headers) > 0 {
589 opts = append(opts, azure.WithHeaders(headers))
592 return azure.New(opts...)
595func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
596 var opts []bedrock.Option
597 if c.cfg.Options.Debug {
598 httpClient := log.NewHTTPClient()
599 opts = append(opts, bedrock.WithHTTPClient(httpClient))
601 if len(headers) > 0 {
602 opts = append(opts, bedrock.WithHeaders(headers))
604 bearerToken := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
605 if bearerToken != "" {
606 opts = append(opts, bedrock.WithAPIKey(bearerToken))
608 return bedrock.New(opts...)
611func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
612 opts := []google.Option{
613 google.WithBaseURL(baseURL),
614 google.WithGeminiAPIKey(apiKey),
616 if c.cfg.Options.Debug {
617 httpClient := log.NewHTTPClient()
618 opts = append(opts, google.WithHTTPClient(httpClient))
620 if len(headers) > 0 {
621 opts = append(opts, google.WithHeaders(headers))
623 return google.New(opts...)
626func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
627 opts := []google.Option{}
628 if c.cfg.Options.Debug {
629 httpClient := log.NewHTTPClient()
630 opts = append(opts, google.WithHTTPClient(httpClient))
632 if len(headers) > 0 {
633 opts = append(opts, google.WithHeaders(headers))
636 project := options["project"]
637 location := options["location"]
639 opts = append(opts, google.WithVertex(project, location))
641 return google.New(opts...)
644func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
649 if model.ProviderOptions == nil {
653 opts, err := anthropic.ParseOptions(model.ProviderOptions)
657 if opts.Thinking != nil {
663func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) {
664 headers := maps.Clone(providerCfg.ExtraHeaders)
666 headers = make(map[string]string)
669 // handle special headers for anthropic
670 if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
671 if v, ok := headers["anthropic-beta"]; ok {
672 headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
674 headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
678 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
679 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
681 switch providerCfg.Type {
683 return c.buildOpenaiProvider(baseURL, apiKey, headers)
685 return c.buildAnthropicProvider(baseURL, apiKey, headers)
686 case openrouter.Name:
687 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
689 return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
691 return c.buildBedrockProvider(headers)
693 return c.buildGoogleProvider(baseURL, apiKey, headers)
694 case "google-vertex":
695 return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
696 case openaicompat.Name:
697 if providerCfg.ID == string(catwalk.InferenceProviderZAI) {
698 if providerCfg.ExtraBody == nil {
699 providerCfg.ExtraBody = map[string]any{}
701 providerCfg.ExtraBody["tool_stream"] = true
703 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody)
705 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
709func isExactoSupported(modelID string) bool {
710 supportedModels := []string{
711 "moonshotai/kimi-k2-0905",
712 "deepseek/deepseek-v3.1-terminus",
714 "openai/gpt-oss-120b",
717 return slices.Contains(supportedModels, modelID)
720func (c *coordinator) Cancel(sessionID string) {
721 c.currentAgent.Cancel(sessionID)
724func (c *coordinator) CancelAll() {
725 c.currentAgent.CancelAll()
728func (c *coordinator) ClearQueue(sessionID string) {
729 c.currentAgent.ClearQueue(sessionID)
732func (c *coordinator) IsBusy() bool {
733 return c.currentAgent.IsBusy()
736func (c *coordinator) IsSessionBusy(sessionID string) bool {
737 return c.currentAgent.IsSessionBusy(sessionID)
740func (c *coordinator) Model() Model {
741 return c.currentAgent.Model()
744func (c *coordinator) UpdateModels(ctx context.Context) error {
745 // build the models again so we make sure we get the latest config
746 large, small, err := c.buildAgentModels(ctx)
750 c.currentAgent.SetModels(large, small)
752 agentCfg, ok := c.cfg.Agents[config.AgentCoder]
754 return errors.New("coder agent not configured")
757 tools, err := c.buildTools(ctx, agentCfg)
761 c.currentAgent.SetTools(tools)
765func (c *coordinator) QueuedPrompts(sessionID string) int {
766 return c.currentAgent.QueuedPrompts(sessionID)
769func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
770 providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
772 return errors.New("model provider not configured")
774 return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list