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, providerID 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
 819	switch {
 820	case apiKey != "":
 821		opts = append(opts, bedrock.WithAPIKey(apiKey))
 822	case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
 823		opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
 824	default:
 825		// Skip, let the SDK do authentication.
 826	}
 827
 828	switch providerID {
 829	case string(catwalk.InferenceProviderBedrockEurope):
 830		opts = append(opts, bedrock.WithRegion("eu-west-1"))
 831	default:
 832		opts = append(opts, bedrock.WithRegion("us-east-1"))
 833	}
 834
 835	return bedrock.New(opts...)
 836}
 837
 838func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
 839	opts := []google.Option{
 840		google.WithBaseURL(baseURL),
 841		google.WithGeminiAPIKey(apiKey),
 842	}
 843	if c.cfg.Config().Options.Debug {
 844		httpClient := log.NewHTTPClient()
 845		opts = append(opts, google.WithHTTPClient(httpClient))
 846	}
 847	if len(headers) > 0 {
 848		opts = append(opts, google.WithHeaders(headers))
 849	}
 850	return google.New(opts...)
 851}
 852
 853func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
 854	opts := []google.Option{}
 855	if c.cfg.Config().Options.Debug {
 856		httpClient := log.NewHTTPClient()
 857		opts = append(opts, google.WithHTTPClient(httpClient))
 858	}
 859	if len(headers) > 0 {
 860		opts = append(opts, google.WithHeaders(headers))
 861	}
 862
 863	project := options["project"]
 864	location := options["location"]
 865
 866	opts = append(opts, google.WithVertex(project, location))
 867
 868	return google.New(opts...)
 869}
 870
 871func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
 872	if model.Think {
 873		return true
 874	}
 875	opts, err := anthropic.ParseOptions(model.ProviderOptions)
 876	return err == nil && opts.Thinking != nil
 877}
 878
 879func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
 880	headers := maps.Clone(providerCfg.ExtraHeaders)
 881	if headers == nil {
 882		headers = make(map[string]string)
 883	}
 884
 885	// handle special headers for anthropic
 886	if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
 887		if v, ok := headers["anthropic-beta"]; ok {
 888			headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
 889		} else {
 890			headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
 891		}
 892	}
 893
 894	apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
 895	baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
 896
 897	switch providerCfg.Type {
 898	case openai.Name:
 899		return c.buildOpenaiProvider(baseURL, apiKey, headers)
 900	case anthropic.Name:
 901		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
 902	case openrouter.Name:
 903		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
 904	case vercel.Name:
 905		return c.buildVercelProvider(baseURL, apiKey, headers)
 906	case azure.Name:
 907		return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
 908	case bedrock.Name:
 909		return c.buildBedrockProvider(apiKey, headers, providerCfg.ID)
 910	case google.Name:
 911		return c.buildGoogleProvider(baseURL, apiKey, headers)
 912	case "google-vertex":
 913		return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
 914	case openaicompat.Name, hyper.Name:
 915		switch providerCfg.ID {
 916		case hyper.Name:
 917			baseURL = hyper.BaseURL() + "/v1"
 918			headers["x-crush-id"] = event.GetID()
 919		case string(catwalk.InferenceProviderZAI):
 920			if providerCfg.ExtraBody == nil {
 921				providerCfg.ExtraBody = map[string]any{}
 922			}
 923			providerCfg.ExtraBody["tool_stream"] = true
 924		}
 925		return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
 926	default:
 927		return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
 928	}
 929}
 930
 931func isExactoSupported(modelID string) bool {
 932	supportedModels := []string{
 933		"moonshotai/kimi-k2-0905",
 934		"deepseek/deepseek-v3.1-terminus",
 935		"z-ai/glm-4.6",
 936		"openai/gpt-oss-120b",
 937		"qwen/qwen3-coder",
 938	}
 939	return slices.Contains(supportedModels, modelID)
 940}
 941
 942func (c *coordinator) Cancel(sessionID string) {
 943	c.currentAgent.Cancel(sessionID)
 944}
 945
 946func (c *coordinator) CancelAll() {
 947	c.currentAgent.CancelAll()
 948}
 949
 950func (c *coordinator) ClearQueue(sessionID string) {
 951	c.currentAgent.ClearQueue(sessionID)
 952}
 953
 954func (c *coordinator) IsBusy() bool {
 955	return c.currentAgent.IsBusy()
 956}
 957
 958func (c *coordinator) IsSessionBusy(sessionID string) bool {
 959	return c.currentAgent.IsSessionBusy(sessionID)
 960}
 961
 962func (c *coordinator) Model() Model {
 963	return c.currentAgent.Model()
 964}
 965
 966func (c *coordinator) UpdateModels(ctx context.Context) error {
 967	// build the models again so we make sure we get the latest config
 968	large, small, err := c.buildAgentModels(ctx, false)
 969	if err != nil {
 970		return err
 971	}
 972	c.currentAgent.SetModels(large, small)
 973
 974	agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
 975	if !ok {
 976		return errCoderAgentNotConfigured
 977	}
 978
 979	tools, err := c.buildTools(ctx, agentCfg, false)
 980	if err != nil {
 981		return err
 982	}
 983	c.currentAgent.SetTools(tools)
 984	return nil
 985}
 986
 987func (c *coordinator) QueuedPrompts(sessionID string) int {
 988	return c.currentAgent.QueuedPrompts(sessionID)
 989}
 990
 991func (c *coordinator) QueuedPromptsList(sessionID string) []string {
 992	return c.currentAgent.QueuedPromptsList(sessionID)
 993}
 994
 995func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
 996	providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
 997	if !ok {
 998		return errModelProviderNotConfigured
 999	}
1000
1001	if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
1002		slog.Error("Failed to refresh OAuth2 token before summarize. Proceeding with existing token.", "error", err)
1003	}
1004
1005	summarize := func() error {
1006		return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
1007	}
1008
1009	err := summarize()
1010	if err != nil && c.isUnauthorized(err) {
1011		if retryErr := c.retryAfterUnauthorized(ctx, providerCfg); retryErr == nil {
1012			return summarize()
1013		}
1014	}
1015
1016	return err
1017}
1018
1019// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
1020func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
1021	if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
1022		return nil
1023	}
1024	slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
1025	return c.refreshOAuth2Token(ctx, providerCfg)
1026}
1027
1028// retryAfterUnauthorized attempts to refresh credentials after receiving a 401
1029// and returns nil if retry should be attempted.
1030func (c *coordinator) retryAfterUnauthorized(ctx context.Context, providerCfg config.ProviderConfig) error {
1031	switch {
1032	case providerCfg.OAuthToken != nil:
1033		slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
1034		return c.refreshOAuth2Token(ctx, providerCfg)
1035	case strings.Contains(providerCfg.APIKeyTemplate, "$"):
1036		slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
1037		return c.refreshApiKeyTemplate(ctx, providerCfg)
1038	default:
1039		return nil
1040	}
1041}
1042
1043func (c *coordinator) isUnauthorized(err error) bool {
1044	var providerErr *fantasy.ProviderError
1045	return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
1046}
1047
1048func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
1049	if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
1050		slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
1051		return err
1052	}
1053	if err := c.UpdateModels(ctx); err != nil {
1054		return err
1055	}
1056	return nil
1057}
1058
1059func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
1060	newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
1061	if err != nil {
1062		slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
1063		return err
1064	}
1065
1066	providerCfg.APIKey = newAPIKey
1067	c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
1068
1069	if err := c.UpdateModels(ctx); err != nil {
1070		return err
1071	}
1072	return nil
1073}
1074
1075// subAgentParams holds the parameters for running a sub-agent.
1076type subAgentParams struct {
1077	Agent          SessionAgent
1078	SessionID      string
1079	AgentMessageID string
1080	ToolCallID     string
1081	Prompt         string
1082	SessionTitle   string
1083	// SessionSetup is an optional callback invoked after session creation
1084	// but before agent execution, for custom session configuration.
1085	SessionSetup func(sessionID string)
1086}
1087
1088// runSubAgent runs a sub-agent and handles session management and cost accumulation.
1089// It creates a sub-session, runs the agent with the given prompt, and propagates
1090// the cost to the parent session.
1091func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
1092	// Create sub-session
1093	agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
1094	session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
1095	if err != nil {
1096		return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
1097	}
1098
1099	// Call session setup function if provided
1100	if params.SessionSetup != nil {
1101		params.SessionSetup(session.ID)
1102	}
1103
1104	// Get model configuration
1105	model := params.Agent.Model()
1106	maxTokens := model.CatwalkCfg.DefaultMaxTokens
1107	if model.ModelCfg.MaxTokens != 0 {
1108		maxTokens = model.ModelCfg.MaxTokens
1109	}
1110
1111	providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
1112	if !ok {
1113		return fantasy.ToolResponse{}, errModelProviderNotConfigured
1114	}
1115
1116	// Run the agent
1117	result, err := params.Agent.Run(ctx, SessionAgentCall{
1118		SessionID:        session.ID,
1119		Prompt:           params.Prompt,
1120		MaxOutputTokens:  maxTokens,
1121		ProviderOptions:  getProviderOptions(model, providerCfg),
1122		Temperature:      model.ModelCfg.Temperature,
1123		TopP:             model.ModelCfg.TopP,
1124		TopK:             model.ModelCfg.TopK,
1125		FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1126		PresencePenalty:  model.ModelCfg.PresencePenalty,
1127		NonInteractive:   true,
1128	})
1129	if err != nil {
1130		return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to generate response: %s", err)), nil
1131	}
1132
1133	// Update parent session cost
1134	if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
1135		return fantasy.ToolResponse{}, err
1136	}
1137
1138	return fantasy.NewTextResponse(result.Response.Content.Text()), nil
1139}
1140
1141// updateParentSessionCost accumulates the cost from a child session to its parent session.
1142func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
1143	childSession, err := c.sessions.Get(ctx, childSessionID)
1144	if err != nil {
1145		return fmt.Errorf("get child session: %w", err)
1146	}
1147
1148	parentSession, err := c.sessions.Get(ctx, parentSessionID)
1149	if err != nil {
1150		return fmt.Errorf("get parent session: %w", err)
1151	}
1152
1153	parentSession.Cost += childSession.Cost
1154
1155	if _, err := c.sessions.Save(ctx, parentSession); err != nil {
1156		return fmt.Errorf("save parent session: %w", err)
1157	}
1158
1159	return nil
1160}
1161
1162// discoverSkills is a thin fallback wrapper used only when no
1163// skills.Manager has been threaded through to the coordinator. All
1164// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
1165// run discovery in advance and pass the results via the manager;
1166// reaching this path means a caller bypassed both. It deliberately does
1167// NOT publish to the package-level broker — there are no subscribers in
1168// that case, so doing so would be misleading without delivering the
1169// snapshot anywhere useful.
1170func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1171	opts := cfg.Config().Options
1172	var paths, disabled []string
1173	if opts != nil {
1174		paths = opts.SkillsPaths
1175		disabled = opts.DisabledSkills
1176	}
1177	var resolver func(string) (string, error)
1178	if r := cfg.Resolver(); r != nil {
1179		resolver = r.ResolveValue
1180	}
1181	allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
1182		SkillsPaths:    paths,
1183		DisabledSkills: disabled,
1184		Resolver:       resolver,
1185	})
1186	logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
1187	return allSkills, activeSkills
1188}
1189
1190// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
1191// (if any) were loaded during this turn and which looked relevant based on
1192// a cheap keyword match against the user prompt. The goal is to surface
1193// "should-have-loaded but didn't" situations for later analysis.
1194//
1195// Logged at Info level under component=skills; heavy fields are elided when
1196// there is nothing interesting to report.
1197func logTurnSkillUsage(
1198	sessionID string,
1199	prompt string,
1200	activeSkills []*skills.Skill,
1201	tracker *skills.Tracker,
1202	before []string,
1203) {
1204	if tracker == nil || len(activeSkills) == 0 {
1205		return
1206	}
1207
1208	after := tracker.LoadedNames()
1209
1210	beforeSet := make(map[string]bool, len(before))
1211	for _, n := range before {
1212		beforeSet[n] = true
1213	}
1214	var loadedThisTurn []string
1215	for _, n := range after {
1216		if !beforeSet[n] {
1217			loadedThisTurn = append(loadedThisTurn, n)
1218		}
1219	}
1220
1221	slog.Info(
1222		"Skill turn summary",
1223		"component", "skills",
1224		"session_id", sessionID,
1225		"prompt_len", len(prompt),
1226		"active_total", len(activeSkills),
1227		"loaded_total", len(after),
1228		"loaded_this_turn", loadedThisTurn,
1229	)
1230}
1231
1232// logDiscoveryStats emits a single structured log line summarising skill
1233// discovery for the current session. It is intentionally low-volume: one
1234// line per session start. Builtin vs user counts are derived from the
1235// SkillState.Path — builtin states use the "builtin/" embed prefix.
1236func logDiscoveryStats(
1237	states []*skills.SkillState,
1238	userPaths []string,
1239	allSkills, activeSkills []*skills.Skill,
1240	disabled []string,
1241) {
1242	var builtinOK, builtinErr, userOK, userErr int
1243	for _, s := range states {
1244		isBuiltin := strings.HasPrefix(s.Path, "builtin/")
1245		switch {
1246		case isBuiltin && s.State == skills.StateNormal:
1247			builtinOK++
1248		case isBuiltin && s.State == skills.StateError:
1249			builtinErr++
1250		case !isBuiltin && s.State == skills.StateNormal:
1251			userOK++
1252		case !isBuiltin && s.State == skills.StateError:
1253			userErr++
1254		}
1255	}
1256
1257	activeNames := make([]string, 0, len(activeSkills))
1258	for _, s := range activeSkills {
1259		activeNames = append(activeNames, s.Name)
1260	}
1261
1262	xml := skills.ToPromptXML(activeSkills)
1263
1264	slog.Info(
1265		"Skill discovery complete",
1266		"component", "skills",
1267		"builtin_ok", builtinOK,
1268		"builtin_errors", builtinErr,
1269		"user_ok", userOK,
1270		"user_errors", userErr,
1271		"user_paths", len(userPaths),
1272		"deduped_total", len(allSkills),
1273		"active", len(activeSkills),
1274		"disabled", len(disabled),
1275		"prompt_bytes", len(xml),
1276		"prompt_tok_est", skills.ApproxTokenCount(xml),
1277		"active_names", activeNames,
1278	)
1279}