Files

1275 lines
40 KiB
Go

package agent
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/notify"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/hooks"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
"golang.org/x/sync/errgroup"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/azure"
"charm.land/fantasy/providers/bedrock"
"charm.land/fantasy/providers/google"
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/fantasy/providers/vercel"
openaisdk "github.com/charmbracelet/openai-go/option"
"github.com/qjebbs/go-jsons"
)
// Coordinator errors.
var (
errCoderAgentNotConfigured = errors.New("coder agent not configured")
errModelProviderNotConfigured = errors.New("model provider not configured")
errLargeModelNotSelected = errors.New("large model not selected")
errSmallModelNotSelected = errors.New("small model not selected")
errLargeModelProviderNotConfigured = errors.New("large model provider not configured")
errSmallModelProviderNotConfigured = errors.New("small model provider not configured")
errLargeModelNotFound = errors.New("large model not found in provider config")
errSmallModelNotFound = errors.New("small model not found in provider config")
)
// Copilot models that use the Responses API instead of Chat Completions.
var copilotResponsesModels = map[string]bool{
"gpt-5.2": true,
"gpt-5.2-codex": true,
"gpt-5.3-codex": true,
"gpt-5.4-mini": true,
"gpt-5-mini": true,
}
type Coordinator interface {
// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
// SetMainAgent(string)
Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
Cancel(sessionID string)
CancelAll()
IsSessionBusy(sessionID string) bool
IsBusy() bool
QueuedPrompts(sessionID string) int
QueuedPromptsList(sessionID string) []string
ClearQueue(sessionID string)
Summarize(context.Context, string) error
Model() Model
UpdateModels(ctx context.Context) error
}
type coordinator struct {
cfg *config.ConfigStore
sessions session.Service
messages message.Service
permissions permission.Service
history history.Service
filetracker filetracker.Service
lspManager *lsp.Manager
notify pubsub.Publisher[notify.Notification]
currentAgent SessionAgent
agents map[string]SessionAgent
// Skills discovery results (session-start snapshot).
allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
activeSkills []*skills.Skill // Post-filter: active skills only.
skillTracker *skills.Tracker
readyWg errgroup.Group
}
func NewCoordinator(
ctx context.Context,
cfg *config.ConfigStore,
sessions session.Service,
messages message.Service,
permissions permission.Service,
history history.Service,
filetracker filetracker.Service,
lspManager *lsp.Manager,
notify pubsub.Publisher[notify.Notification],
) (Coordinator, error) {
// Discover skills once at session start.
allSkills, activeSkills := discoverSkills(cfg)
skillTracker := skills.NewTracker(activeSkills)
c := &coordinator{
cfg: cfg,
sessions: sessions,
messages: messages,
permissions: permissions,
history: history,
filetracker: filetracker,
lspManager: lspManager,
notify: notify,
agents: make(map[string]SessionAgent),
allSkills: allSkills,
activeSkills: activeSkills,
skillTracker: skillTracker,
}
agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
if !ok {
return nil, errCoderAgentNotConfigured
}
// TODO: make this dynamic when we support multiple agents
prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
if err != nil {
return nil, err
}
agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
if err != nil {
return nil, err
}
c.currentAgent = agent
c.agents[config.AgentCoder] = agent
return c, nil
}
// Run implements Coordinator.
func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
if err := c.readyWg.Wait(); err != nil {
return nil, err
}
// refresh models before each run
if err := c.UpdateModels(ctx); err != nil {
return nil, fmt.Errorf("failed to update models: %w", err)
}
model := c.currentAgent.Model()
maxTokens := model.CatwalkCfg.DefaultMaxTokens
if model.ModelCfg.MaxTokens != 0 {
maxTokens = model.ModelCfg.MaxTokens
}
if !model.CatwalkCfg.SupportsImages && attachments != nil {
// filter out image attachments
filteredAttachments := make([]message.Attachment, 0, len(attachments))
for _, att := range attachments {
if att.IsText() {
filteredAttachments = append(filteredAttachments, att)
}
}
attachments = filteredAttachments
}
providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
if !ok {
return nil, errModelProviderNotConfigured
}
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
// NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
// depends on the flow below. If refresh fails, proceed with the token we have.
slog.Error("Failed to refresh OAuth2 token. Proceeding with existing token.", "error", err)
}
run := func() (*fantasy.AgentResult, error) {
return c.currentAgent.Run(ctx, SessionAgentCall{
SessionID: sessionID,
Prompt: prompt,
Attachments: attachments,
MaxOutputTokens: maxTokens,
ProviderOptions: mergedOptions,
Temperature: temp,
TopP: topP,
TopK: topK,
FrequencyPenalty: freqPenalty,
PresencePenalty: presPenalty,
})
}
beforeLoaded := c.skillTracker.LoadedNames()
result, originalErr := run()
logTurnSkillUsage(sessionID, prompt, c.activeSkills, c.skillTracker, beforeLoaded)
if c.isUnauthorized(originalErr) {
if err := c.retryAfterUnauthorized(ctx, providerCfg); err == nil {
return run()
}
}
return result, originalErr
}
func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
options := fantasy.ProviderOptions{}
cfgOpts := []byte("{}")
providerCfgOpts := []byte("{}")
catwalkOpts := []byte("{}")
if model.ModelCfg.ProviderOptions != nil {
data, err := json.Marshal(model.ModelCfg.ProviderOptions)
if err == nil {
cfgOpts = data
}
}
if providerCfg.ProviderOptions != nil {
data, err := json.Marshal(providerCfg.ProviderOptions)
if err == nil {
providerCfgOpts = data
}
}
if model.CatwalkCfg.Options.ProviderOptions != nil {
data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
if err == nil {
catwalkOpts = data
}
}
readers := []io.Reader{
bytes.NewReader(catwalkOpts),
bytes.NewReader(providerCfgOpts),
bytes.NewReader(cfgOpts),
}
got, err := jsons.Merge(readers)
if err != nil {
slog.Error("Could not merge call config", "err", err)
return options
}
mergedOptions := make(map[string]any)
err = json.Unmarshal([]byte(got), &mergedOptions)
if err != nil {
slog.Error("Could not create config for call", "err", err)
return options
}
switch providerCfg.Type {
case openai.Name, azure.Name:
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
}
if openai.IsResponsesModel(model.CatwalkCfg.ID) {
if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
mergedOptions["reasoning_summary"] = "auto"
mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
}
parsed, err := openai.ParseResponsesOptions(mergedOptions)
if err == nil {
options[openai.Name] = parsed
}
} else {
parsed, err := openai.ParseOptions(mergedOptions)
if err == nil {
options[openai.Name] = parsed
}
}
case anthropic.Name, bedrock.Name:
var (
_, hasEffort = mergedOptions["effort"]
_, hasThink = mergedOptions["thinking"]
)
switch {
case !hasEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason:
mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
case !hasThink && model.ModelCfg.Think:
mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
}
parsed, err := anthropic.ParseOptions(mergedOptions)
if err == nil {
options[anthropic.Name] = parsed
}
case openrouter.Name:
_, hasReasoning := mergedOptions["reasoning"]
if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
mergedOptions["reasoning"] = map[string]any{
"enabled": true,
"effort": model.ModelCfg.ReasoningEffort,
}
}
parsed, err := openrouter.ParseOptions(mergedOptions)
if err == nil {
options[openrouter.Name] = parsed
}
case vercel.Name:
_, hasReasoning := mergedOptions["reasoning"]
if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
mergedOptions["reasoning"] = map[string]any{
"enabled": true,
"effort": model.ModelCfg.ReasoningEffort,
}
}
parsed, err := vercel.ParseOptions(mergedOptions)
if err == nil {
options[vercel.Name] = parsed
}
case google.Name:
_, hasReasoning := mergedOptions["thinking_config"]
if !hasReasoning {
if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
mergedOptions["thinking_config"] = map[string]any{
"thinking_budget": 2000,
"include_thoughts": true,
}
} else {
mergedOptions["thinking_config"] = map[string]any{
"thinking_level": model.ModelCfg.ReasoningEffort,
"include_thoughts": true,
}
}
}
parsed, err := google.ParseOptions(mergedOptions)
if err == nil {
options[google.Name] = parsed
}
case openaicompat.Name, hyper.Name:
extraBody := make(map[string]any)
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
switch providerCfg.ID {
case string(catwalk.InferenceProviderIoNet):
extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
default:
mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
}
}
// "reasoning effort" is a standard OpenAI field, but "thinking" is not.
// Setting it in the right way for each provider.
// TODO: Abstract this in Fantasy somehow?
// TODO: Allow custom providers to specify how to set this?
switch providerCfg.ID {
case hyper.Name:
extraBody["thinking"] = model.ModelCfg.Think
case string(catwalk.InferenceProviderIoNet):
if _, ok := extraBody["reasoning"]; !ok && model.CatwalkCfg.CanReason {
if model.ModelCfg.Think {
extraBody["reasoning"] = map[string]string{"effort": "medium"}
} else {
extraBody["reasoning"] = map[string]string{"effort": "none"}
}
}
case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
if model.ModelCfg.Think || model.ModelCfg.ReasoningEffort != "" {
extraBody["thinking"] = map[string]any{
"type": "enabled",
}
} else {
extraBody["thinking"] = map[string]any{
"type": "disabled",
}
}
}
mergedOptions["extra_body"] = extraBody
parsed, err := openaicompat.ParseOptions(mergedOptions)
if err == nil {
options[openaicompat.Name] = parsed
}
}
return options
}
func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
modelOptions := getProviderOptions(model, cfg)
temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
return modelOptions, temp, topP, topK, freqPenalty, presPenalty
}
func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
large, small, err := c.buildAgentModels(ctx, isSubAgent)
if err != nil {
return nil, err
}
largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
result := NewSessionAgent(SessionAgentOptions{
LargeModel: large,
SmallModel: small,
SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
SystemPrompt: "",
IsSubAgent: isSubAgent,
DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
IsYolo: c.permissions.SkipRequests(),
Sessions: c.sessions,
Messages: c.messages,
Tools: nil,
Notify: c.notify,
})
c.readyWg.Go(func() error {
systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg)
if err != nil {
return err
}
result.SetSystemPrompt(systemPrompt)
return nil
})
c.readyWg.Go(func() error {
tools, err := c.buildTools(ctx, agent, isSubAgent)
if err != nil {
return err
}
result.SetTools(tools)
return nil
})
return result, nil
}
func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubAgent bool) ([]fantasy.AgentTool, error) {
var allTools []fantasy.AgentTool
if slices.Contains(agent.AllowedTools, AgentToolName) {
agentTool, err := c.agentTool(ctx)
if err != nil {
return nil, err
}
allTools = append(allTools, agentTool)
}
if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
if err != nil {
return nil, err
}
allTools = append(allTools, agenticFetchTool)
}
// Get the model name for the agent
modelID := ""
if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
modelID = model.ID
}
}
logFile := filepath.Join(c.cfg.Config().Options.DataDirectory, "logs", "crush.log")
// Build hook runner if PreToolUse hooks are configured.
var hookRunner *hooks.Runner
if preToolHooks := c.cfg.Config().Hooks[hooks.EventPreToolUse]; len(preToolHooks) > 0 {
hookRunner = hooks.NewRunner(preToolHooks, c.cfg.WorkingDir(), c.cfg.WorkingDir())
}
allTools = append(allTools,
tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelID),
tools.NewCrushInfoTool(c.cfg, c.lspManager, c.allSkills, c.activeSkills, c.skillTracker),
tools.NewCrushLogsTool(logFile),
tools.NewJobOutputTool(),
tools.NewJobKillTool(),
tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
tools.NewGlobTool(c.cfg.WorkingDir()),
tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
tools.NewSourcegraphTool(nil),
tools.NewTodosTool(c.sessions),
tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
)
// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
}
if len(c.cfg.Config().MCP) > 0 {
allTools = append(
allTools,
tools.NewListMCPResourcesTool(c.cfg, c.permissions),
tools.NewReadMCPResourceTool(c.cfg, c.permissions),
)
}
var filteredTools []fantasy.AgentTool
for _, tool := range allTools {
if slices.Contains(agent.AllowedTools, tool.Info().Name) {
filteredTools = append(filteredTools, tool)
}
}
for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
if agent.AllowedMCP == nil {
// No MCP restrictions
filteredTools = append(filteredTools, tool)
continue
}
if len(agent.AllowedMCP) == 0 {
// No MCPs allowed
slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
break
}
for mcp, tools := range agent.AllowedMCP {
if mcp != tool.MCP() {
continue
}
if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
filteredTools = append(filteredTools, tool)
break
}
slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
}
}
slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
return strings.Compare(a.Info().Name, b.Info().Name)
})
// Wrap tools with hook interception for the top-level agent only.
// Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run
// without hook interception to avoid firing the user's hook N times
// per delegated turn. The top-level invocation of the sub-agent tool
// itself is still wrapped from the coder's side.
filteredTools = wrapToolsWithHooks(filteredTools, hookRunner, isSubAgent)
return filteredTools, nil
}
// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
if !ok {
return Model{}, Model{}, errLargeModelNotSelected
}
smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
if !ok {
return Model{}, Model{}, errSmallModelNotSelected
}
largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
if !ok {
return Model{}, Model{}, errLargeModelProviderNotConfigured
}
largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
if err != nil {
return Model{}, Model{}, err
}
smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
if !ok {
return Model{}, Model{}, errSmallModelProviderNotConfigured
}
smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
if err != nil {
return Model{}, Model{}, err
}
var largeCatwalkModel *catwalk.Model
var smallCatwalkModel *catwalk.Model
for _, m := range largeProviderCfg.Models {
if m.ID == largeModelCfg.Model {
largeCatwalkModel = &m
}
}
for _, m := range smallProviderCfg.Models {
if m.ID == smallModelCfg.Model {
smallCatwalkModel = &m
}
}
if largeCatwalkModel == nil {
return Model{}, Model{}, errLargeModelNotFound
}
if smallCatwalkModel == nil {
return Model{}, Model{}, errSmallModelNotFound
}
largeModelID := largeModelCfg.Model
smallModelID := smallModelCfg.Model
if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
largeModelID += ":exacto"
}
if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
smallModelID += ":exacto"
}
largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
if err != nil {
return Model{}, Model{}, err
}
smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
if err != nil {
return Model{}, Model{}, err
}
return Model{
Model: largeModel,
CatwalkCfg: *largeCatwalkModel,
ModelCfg: largeModelCfg,
FlatRate: largeProviderCfg.FlatRate,
}, Model{
Model: smallModel,
CatwalkCfg: *smallCatwalkModel,
ModelCfg: smallModelCfg,
FlatRate: smallProviderCfg.FlatRate,
}, nil
}
func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
var opts []anthropic.Option
switch {
case strings.HasPrefix(apiKey, "Bearer "):
// NOTE: Prevent the SDK from picking up the API key from env.
os.Setenv("ANTHROPIC_API_KEY", "")
headers["Authorization"] = apiKey
case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina):
// NOTE: Prevent the SDK from picking up the API key from env.
os.Setenv("ANTHROPIC_API_KEY", "")
headers["Authorization"] = "Bearer " + apiKey
case apiKey != "":
// X-Api-Key header
opts = append(opts, anthropic.WithAPIKey(apiKey))
}
if len(headers) > 0 {
opts = append(opts, anthropic.WithHeaders(headers))
}
if baseURL != "" {
opts = append(opts, anthropic.WithBaseURL(baseURL))
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, anthropic.WithHTTPClient(httpClient))
}
return anthropic.New(opts...)
}
func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
opts := []openai.Option{
openai.WithAPIKey(apiKey),
openai.WithUseResponsesAPI(),
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, openai.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, openai.WithHeaders(headers))
}
if baseURL != "" {
opts = append(opts, openai.WithBaseURL(baseURL))
}
return openai.New(opts...)
}
func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
opts := []openrouter.Option{
openrouter.WithAPIKey(apiKey),
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, openrouter.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, openrouter.WithHeaders(headers))
}
return openrouter.New(opts...)
}
func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
opts := []vercel.Option{
vercel.WithAPIKey(apiKey),
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, vercel.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, vercel.WithHeaders(headers))
}
return vercel.New(opts...)
}
func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
opts := []openaicompat.Option{
openaicompat.WithBaseURL(baseURL),
openaicompat.WithAPIKey(apiKey),
}
// Set HTTP client based on provider and debug mode.
var httpClient *http.Client
if providerID == string(catwalk.InferenceProviderCopilot) {
opts = append(opts,
openaicompat.WithUseResponsesAPI(),
openaicompat.WithResponsesAPIFunc(func(modelID string) bool {
return copilotResponsesModels[modelID]
}),
)
httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
} else if c.cfg.Config().Options.Debug {
httpClient = log.NewHTTPClient()
}
if httpClient != nil {
opts = append(opts, openaicompat.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, openaicompat.WithHeaders(headers))
}
for extraKey, extraValue := range extraBody {
opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
}
return openaicompat.New(opts...)
}
func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
opts := []azure.Option{
azure.WithBaseURL(baseURL),
azure.WithAPIKey(apiKey),
azure.WithUseResponsesAPI(),
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, azure.WithHTTPClient(httpClient))
}
if options == nil {
options = make(map[string]string)
}
if apiVersion, ok := options["apiVersion"]; ok {
opts = append(opts, azure.WithAPIVersion(apiVersion))
}
if len(headers) > 0 {
opts = append(opts, azure.WithHeaders(headers))
}
return azure.New(opts...)
}
func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string) (fantasy.Provider, error) {
var opts []bedrock.Option
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, bedrock.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, bedrock.WithHeaders(headers))
}
switch {
case apiKey != "":
opts = append(opts, bedrock.WithAPIKey(apiKey))
case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
default:
// Skip, let the SDK do authentication.
}
return bedrock.New(opts...)
}
func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
opts := []google.Option{
google.WithBaseURL(baseURL),
google.WithGeminiAPIKey(apiKey),
}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, google.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, google.WithHeaders(headers))
}
return google.New(opts...)
}
func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
opts := []google.Option{}
if c.cfg.Config().Options.Debug {
httpClient := log.NewHTTPClient()
opts = append(opts, google.WithHTTPClient(httpClient))
}
if len(headers) > 0 {
opts = append(opts, google.WithHeaders(headers))
}
project := options["project"]
location := options["location"]
opts = append(opts, google.WithVertex(project, location))
return google.New(opts...)
}
func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
if model.Think {
return true
}
opts, err := anthropic.ParseOptions(model.ProviderOptions)
return err == nil && opts.Thinking != nil
}
func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
headers := maps.Clone(providerCfg.ExtraHeaders)
if headers == nil {
headers = make(map[string]string)
}
// handle special headers for anthropic
if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
if v, ok := headers["anthropic-beta"]; ok {
headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
} else {
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
}
}
apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
switch providerCfg.Type {
case openai.Name:
return c.buildOpenaiProvider(baseURL, apiKey, headers)
case anthropic.Name:
return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
case openrouter.Name:
return c.buildOpenrouterProvider(baseURL, apiKey, headers)
case vercel.Name:
return c.buildVercelProvider(baseURL, apiKey, headers)
case azure.Name:
return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
case bedrock.Name:
return c.buildBedrockProvider(apiKey, headers)
case google.Name:
return c.buildGoogleProvider(baseURL, apiKey, headers)
case "google-vertex":
return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
case openaicompat.Name, hyper.Name:
switch providerCfg.ID {
case hyper.Name:
baseURL = hyper.BaseURL() + "/v1"
headers["x-crush-id"] = event.GetID()
case string(catwalk.InferenceProviderZAI):
if providerCfg.ExtraBody == nil {
providerCfg.ExtraBody = map[string]any{}
}
providerCfg.ExtraBody["tool_stream"] = true
}
return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
default:
return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
}
}
func isExactoSupported(modelID string) bool {
supportedModels := []string{
"moonshotai/kimi-k2-0905",
"deepseek/deepseek-v3.1-terminus",
"z-ai/glm-4.6",
"openai/gpt-oss-120b",
"qwen/qwen3-coder",
}
return slices.Contains(supportedModels, modelID)
}
func (c *coordinator) Cancel(sessionID string) {
c.currentAgent.Cancel(sessionID)
}
func (c *coordinator) CancelAll() {
c.currentAgent.CancelAll()
}
func (c *coordinator) ClearQueue(sessionID string) {
c.currentAgent.ClearQueue(sessionID)
}
func (c *coordinator) IsBusy() bool {
return c.currentAgent.IsBusy()
}
func (c *coordinator) IsSessionBusy(sessionID string) bool {
return c.currentAgent.IsSessionBusy(sessionID)
}
func (c *coordinator) Model() Model {
return c.currentAgent.Model()
}
func (c *coordinator) UpdateModels(ctx context.Context) error {
// build the models again so we make sure we get the latest config
large, small, err := c.buildAgentModels(ctx, false)
if err != nil {
return err
}
c.currentAgent.SetModels(large, small)
agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
if !ok {
return errCoderAgentNotConfigured
}
tools, err := c.buildTools(ctx, agentCfg, false)
if err != nil {
return err
}
c.currentAgent.SetTools(tools)
return nil
}
func (c *coordinator) QueuedPrompts(sessionID string) int {
return c.currentAgent.QueuedPrompts(sessionID)
}
func (c *coordinator) QueuedPromptsList(sessionID string) []string {
return c.currentAgent.QueuedPromptsList(sessionID)
}
func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
if !ok {
return errModelProviderNotConfigured
}
if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
slog.Error("Failed to refresh OAuth2 token before summarize. Proceeding with existing token.", "error", err)
}
summarize := func() error {
return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg))
}
err := summarize()
if err != nil && c.isUnauthorized(err) {
if retryErr := c.retryAfterUnauthorized(ctx, providerCfg); retryErr == nil {
return summarize()
}
}
return err
}
// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
return nil
}
slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
return c.refreshOAuth2Token(ctx, providerCfg)
}
// retryAfterUnauthorized attempts to refresh credentials after receiving a 401
// and returns nil if retry should be attempted.
func (c *coordinator) retryAfterUnauthorized(ctx context.Context, providerCfg config.ProviderConfig) error {
switch {
case providerCfg.OAuthToken != nil:
slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
return c.refreshOAuth2Token(ctx, providerCfg)
case strings.Contains(providerCfg.APIKeyTemplate, "$"):
slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
return c.refreshApiKeyTemplate(ctx, providerCfg)
default:
return nil
}
}
func (c *coordinator) isUnauthorized(err error) bool {
var providerErr *fantasy.ProviderError
return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
}
func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
return err
}
if err := c.UpdateModels(ctx); err != nil {
return err
}
return nil
}
func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
if err != nil {
slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
return err
}
providerCfg.APIKey = newAPIKey
c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
if err := c.UpdateModels(ctx); err != nil {
return err
}
return nil
}
// subAgentParams holds the parameters for running a sub-agent.
type subAgentParams struct {
Agent SessionAgent
SessionID string
AgentMessageID string
ToolCallID string
Prompt string
SessionTitle string
// SessionSetup is an optional callback invoked after session creation
// but before agent execution, for custom session configuration.
SessionSetup func(sessionID string)
}
// runSubAgent runs a sub-agent and handles session management and cost accumulation.
// It creates a sub-session, runs the agent with the given prompt, and propagates
// the cost to the parent session.
func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
// Create sub-session
agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
}
// Call session setup function if provided
if params.SessionSetup != nil {
params.SessionSetup(session.ID)
}
// Get model configuration
model := params.Agent.Model()
maxTokens := model.CatwalkCfg.DefaultMaxTokens
if model.ModelCfg.MaxTokens != 0 {
maxTokens = model.ModelCfg.MaxTokens
}
providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
if !ok {
return fantasy.ToolResponse{}, errModelProviderNotConfigured
}
// Run the agent
result, err := params.Agent.Run(ctx, SessionAgentCall{
SessionID: session.ID,
Prompt: params.Prompt,
MaxOutputTokens: maxTokens,
ProviderOptions: getProviderOptions(model, providerCfg),
Temperature: model.ModelCfg.Temperature,
TopP: model.ModelCfg.TopP,
TopK: model.ModelCfg.TopK,
FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
PresencePenalty: model.ModelCfg.PresencePenalty,
NonInteractive: true,
})
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to generate response: %s", err)), nil
}
// Update parent session cost
if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
return fantasy.ToolResponse{}, err
}
return fantasy.NewTextResponse(result.Response.Content.Text()), nil
}
// updateParentSessionCost accumulates the cost from a child session to its parent session.
func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
childSession, err := c.sessions.Get(ctx, childSessionID)
if err != nil {
return fmt.Errorf("get child session: %w", err)
}
parentSession, err := c.sessions.Get(ctx, parentSessionID)
if err != nil {
return fmt.Errorf("get parent session: %w", err)
}
parentSession.Cost += childSession.Cost
if _, err := c.sessions.Save(ctx, parentSession); err != nil {
return fmt.Errorf("save parent session: %w", err)
}
return nil
}
// discoverSkills runs the skill discovery pipeline and returns both the
// pre-filter (all discovered, after dedup) and post-filter (active) lists.
// It also emits a single diagnostic log line summarising the outcome to
// help track skill-loading health over time.
func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
builtin, builtinStates := skills.DiscoverBuiltinWithStates()
discovered := append([]*skills.Skill(nil), builtin...)
var userStates []*skills.SkillState
var userPaths []string
opts := cfg.Config().Options
if opts != nil && len(opts.SkillsPaths) > 0 {
userPaths = make([]string, 0, len(opts.SkillsPaths))
for _, pth := range opts.SkillsPaths {
expanded := home.Long(pth)
if strings.HasPrefix(expanded, "$") {
if resolved, err := cfg.Resolver().ResolveValue(expanded); err == nil {
expanded = resolved
}
}
userPaths = append(userPaths, expanded)
}
var userSkills []*skills.Skill
userSkills, userStates = skills.DiscoverWithStates(userPaths)
discovered = append(discovered, userSkills...)
}
allSkills = skills.Deduplicate(discovered)
var disabledSkills []string
if opts != nil {
disabledSkills = opts.DisabledSkills
}
activeSkills = skills.Filter(allSkills, disabledSkills)
allStates := append([]*skills.SkillState(nil), builtinStates...)
allStates = append(allStates, userStates...)
allStates = skills.DeduplicateStates(allStates)
slices.SortStableFunc(allStates, func(a, b *skills.SkillState) int {
return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
})
skills.SetLatestStates(allStates)
skills.PublishStates(allStates)
logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
return allSkills, activeSkills
}
// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
// (if any) were loaded during this turn and which looked relevant based on
// a cheap keyword match against the user prompt. The goal is to surface
// "should-have-loaded but didn't" situations for later analysis.
//
// Logged at Info level under component=skills; heavy fields are elided when
// there is nothing interesting to report.
func logTurnSkillUsage(
sessionID string,
prompt string,
activeSkills []*skills.Skill,
tracker *skills.Tracker,
before []string,
) {
if tracker == nil || len(activeSkills) == 0 {
return
}
after := tracker.LoadedNames()
beforeSet := make(map[string]bool, len(before))
for _, n := range before {
beforeSet[n] = true
}
var loadedThisTurn []string
for _, n := range after {
if !beforeSet[n] {
loadedThisTurn = append(loadedThisTurn, n)
}
}
slog.Info("Skill turn summary",
"component", "skills",
"session_id", sessionID,
"prompt_len", len(prompt),
"active_total", len(activeSkills),
"loaded_total", len(after),
"loaded_this_turn", loadedThisTurn,
)
}
// logDiscoveryStats emits a single structured log line summarising skill
// discovery for the current session. It is intentionally low-volume: one
// line per session start.
func logDiscoveryStats(
builtin []*skills.Skill,
builtinStates, userStates []*skills.SkillState,
userPaths []string,
allSkills, activeSkills []*skills.Skill,
disabled []string,
) {
countErrors := func(states []*skills.SkillState) int {
n := 0
for _, s := range states {
if s.State == skills.StateError {
n++
}
}
return n
}
userOK := 0
for _, s := range userStates {
if s.State == skills.StateNormal {
userOK++
}
}
activeNames := make([]string, 0, len(activeSkills))
for _, s := range activeSkills {
activeNames = append(activeNames, s.Name)
}
xml := skills.ToPromptXML(activeSkills)
slog.Info("Skill discovery complete",
"component", "skills",
"builtin_ok", len(builtin),
"builtin_errors", countErrors(builtinStates),
"user_ok", userOK,
"user_errors", countErrors(userStates),
"user_paths", len(userPaths),
"deduped_total", len(allSkills),
"active", len(activeSkills),
"disabled", len(disabled),
"prompt_bytes", len(xml),
"prompt_tok_est", skills.ApproxTokenCount(xml),
"active_names", activeNames,
)
}