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