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