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