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