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