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