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