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