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