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