coordinator.go

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