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