coordinator.go

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