coordinator.go

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