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