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