Files
2026-07-13 13:00:08 +08:00

2005 lines
76 KiB
Go

// Package boot assembles a ready-to-drive control.Controller from configuration:
// it loads config, resolves the model(s), builds the tool registry (built-ins +
// plugins), wires the permission gate, and constructs the executor — optionally
// wrapping it in a two-model Coordinator. It is the one place that turns "what the
// user configured" into "a Controller a frontend can drive", so every frontend —
// the terminal TUI, the HTTP/SSE server, the desktop webview — shares the exact
// same assembly instead of each re-deriving it. Frontends pass only a sink and a
// couple of run knobs; everything else comes from config.
package boot
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/capability"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/environment"
"reasonix/internal/event"
"reasonix/internal/guardian"
"reasonix/internal/history"
"reasonix/internal/hook"
"reasonix/internal/installsource"
"reasonix/internal/instruction"
"reasonix/internal/jobs"
"reasonix/internal/lsp"
"reasonix/internal/memory"
"reasonix/internal/memorycompiler"
"reasonix/internal/migration"
"reasonix/internal/netclient"
"reasonix/internal/outputstyle"
"reasonix/internal/permission"
"reasonix/internal/planmode"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/secrets"
"reasonix/internal/skill"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
"reasonix/internal/tool/sessiontool"
)
// ErrUnknownModel is returned by Build when the configured model can't be
// resolved to a provider — e.g. a default_model left over from a renamed or
// removed provider. Callers can detect it (errors.Is) to re-run setup.
var ErrUnknownModel = errors.New("unknown model")
func agentKeepPolicy(keep []string) agent.KeepPolicy {
if keep == nil {
return agent.KeepErrors
}
var p agent.KeepPolicy
for _, k := range keep {
switch strings.TrimSpace(k) {
case "errors":
p |= agent.KeepErrors
case "user_marked":
p |= agent.KeepUserMarked
}
}
return p
}
// Options carries the per-run knobs a frontend chooses; everything else is read
// from configuration. Model "" falls back to the configured default_model;
// MaxSteps 0 uses the config/default. RequireKey forces the executor's API key to
// be present (run/serve pass true so a missing key fails fast; chat/desktop pass
// false so the UI is reachable before a key is set). Sink receives the agent's
// typed event stream.
type Options struct {
Model string
MaxSteps int
RequireKey bool
Sink event.Sink
// EffortOverride is a session-local reasoning effort override. Nil means use
// the resolved provider config; a non-nil empty string means provider default.
EffortOverride *string
// Stderr is the writer for diagnostic warnings and plugin subprocess
// stderr output. When nil, defaults to os.Stderr. Set to io.Discard
// during model switch inside a bubbletea session to prevent any output
// from corrupting the TUI's terminal raw mode.
Stderr io.Writer
// WorkspaceRoot is the project root directory for config, skills, memory,
// commands, hooks, and tool confinement. When empty, the current working
// directory is used (CLI default). Desktop tabs pass their project root here
// so each tab loads its own config/skills/hooks without changing the process
// cwd — enabling concurrent multi-project sessions.
WorkspaceRoot string
// ExtraPlugins are session-scoped MCP servers supplied by a host transport
// (for example ACP session/new). They are connected eagerly for this
// controller but are not persisted to reasonix.toml.
ExtraPlugins []plugin.Spec
// TokenMode selects the session's runtime profile. Empty/full/balanced preserves
// the normal capability surface. "economy" keeps the core coding tools visible
// and moves optional sources behind connect_tool_source. "delivery" keeps the
// full surface and adds a stable completion-and-verification contract.
TokenMode string
// SessionDir overrides where persisted chat transcripts are written. When
// empty, the shared CLI/global session directory is used.
SessionDir string
// SharedHost is an optional plugin.Host shared across controllers for the
// same workspace root. When set, boot.Build reuses its running clients
// instead of creating new subprocesses, and the caller manages the host's
// lifecycle. When nil, Build creates and owns a new host as before.
SharedHost *plugin.Host
// CleanupPendingReconciler retries delayed physical cleanup for session
// artifacts left by a previous process. Nil uses the core physical-delete
// reconciler; frontends with different deletion semantics can override it.
CleanupPendingReconciler func(sessionDir string) error
// ApprovalTimeout bounds how long a tool-approval or ask prompt blocks for a
// user decision. Zero (default) waits forever — correct for an interactive
// terminal. Headless/bot frontends pass a positive value so an unanswered
// prompt can't wedge the session indefinitely (#4626, #4402).
ApprovalTimeout time.Duration
// SessionRecoveryMeta and OnSessionRecovered let richer frontends attach
// local UI metadata to automatic transcript recovery branches.
SessionRecoveryMeta func(control.SessionRecoveryRequest) agent.BranchMeta
OnSessionRecovered func(control.SessionRecoveryInfo) error
// FileOverlay and TerminalRunner let a host transport (ACP) serve file
// content from editor buffers and run foreground bash in a host terminal.
// Both only change where tool I/O happens — tool names, descriptions, and
// schemas stay byte-identical, so the provider-visible surface is unchanged.
FileOverlay builtin.FileOverlay
TerminalRunner builtin.TerminalRunner
}
// Build loads config, resolves the model(s), and returns a Controller wrapping a
// single Agent, or a two-model Coordinator when agent.planner_model is set. The
// returned controller owns plugin subprocesses; call Close (via Controller.Close)
// to release them.
func Build(ctx context.Context, opts Options) (*control.Controller, error) {
stderr := opts.Stderr
if stderr == nil {
stderr = os.Stderr
}
root := resolveWorkspaceRoot(opts.WorkspaceRoot)
// One-time import of v1/v0.5 legacy config — runs before Load so the freshly
// written config + ~/.env are picked up this same boot. CLI Run also calls this
// before config-only commands; this call stays as the shared frontend fallback.
migrated, migErr := config.MigrateLegacyIfNeededForRoot(root)
cfg, err := config.LoadForRoot(root)
if err != nil {
return nil, err
}
// Arm the credential-protection layers from the user-global [secrets]
// section before any tool, hook, or plugin subprocess can spawn. Package
// globals are correct here because [secrets] is user-global (project
// reasonix.toml cannot override it), so concurrent workspaces agree.
secrets.SetRedactToolOutput(cfg.SecretsRedactToolOutput())
secrets.SetFilterSubprocessEnv(cfg.Secrets.FilterSubprocessEnv)
secrets.SetProtectSensitiveFiles(cfg.Secrets.ProtectSensitiveFiles)
modelName := opts.Model
if modelName == "" {
modelName = cfg.DefaultModel
}
config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, modelName)
tokenMode := NormalizeTokenMode(opts.TokenMode)
tokenEconomy := tokenMode == TokenModeEconomy
tokenDelivery := tokenMode == TokenModeDelivery
runtimeProfile := capability.ProfileBalanced
if tokenEconomy {
runtimeProfile = capability.ProfileEconomy
} else if tokenDelivery {
runtimeProfile = capability.ProfileDelivery
}
keepPolicy := agentKeepPolicy(cfg.Agent.Keep)
entry, ok := cfg.ResolveModel(modelName)
if !ok {
return nil, fmt.Errorf("%w %q (configured: %s); note: defining [[providers]] replaces the built-in presets, so add a [[providers]] entry for it or use a configured name, or run `reasonix setup` to reconfigure", ErrUnknownModel, modelName, providerNames(cfg))
}
modelRef := entry.Name + "/" + entry.Model
if opts.EffortOverride != nil {
entry.Effort = *opts.EffortOverride
if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" {
entry.Thinking = "adaptive"
}
}
if opts.RequireKey {
if err := cfg.Validate(modelName); err != nil {
return nil, err
}
}
// Serialize the frontend's sink once: background jobs (below) emit from their
// own goroutines, which can overlap a running turn's emission, so every emitter
// shares this synchronized sink. The job manager is session-scoped — its jobs
// outlive a turn and are cancelled by Controller.Close.
sink := event.Sync(opts.Sink)
planModePolicy := planmode.Policy{
AllowedTools: cfg.Agent.PlanModeAllowedTools,
ReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
}
if ignored := planModePolicy.IgnoredAllowedTools(); len(ignored) > 0 {
detail := fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", "))
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "Some plan-mode tool settings were ignored.",
Detail: detail,
})
}
if ignored := planModePolicy.IgnoredReadOnlyCommands(); len(ignored) > 0 {
detail := fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", "))
sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Text: "Some plan-mode command settings were ignored.",
Detail: detail,
})
}
if migErr != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Config migration did not complete.", Detail: "config migration from ~/.reasonix failed: " + migErr.Error()})
} else if migrated != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: migrated.Notice()})
}
migration.MigrateLegacyMemorySources(sink)
migration.MigrateLegacySessionSources(sink)
if ignored := cfg.IgnoredProjectDefaultModel(); ignored != "" {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Ignored the project config's default_model.", Detail: fmt.Sprintf("./reasonix.toml sets default_model = %q but no configured provider serves it; using %q from your user config instead. Edit or remove that default_model line to silence this notice.", ignored, cfg.DefaultModel)})
}
// A resolvable model whose API key env is unset would otherwise build fine
// (RequireKey is false so the UI stays reachable) and then fail silently on the
// first request, showing as an empty/dead model. Surface the cause up front.
if !opts.RequireKey && entry.RequiresAPIKey() && entry.APIKey() == "" {
sink.Emit(event.Event{Kind: event.Notice, Text: "Selected model is missing its API key.", Detail: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
}
jm := jobs.NewManager(sink, jobs.WithStalledWarningAfter(time.Duration(cfg.BackgroundJobStalledWarningSeconds())*time.Second))
sessionDir := opts.SessionDir
if sessionDir == "" {
sessionDir = config.SessionDir()
}
reconcileCleanupPending := opts.CleanupPendingReconciler
if reconcileCleanupPending == nil {
reconcileCleanupPending = control.ReconcileCleanupPending
}
if err := reconcileCleanupPending(sessionDir); err != nil {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "cleanup-pending reconciliation failed: " + err.Error()})
}
proxySpec := cfg.NetworkProxySpec()
if err := netclient.Validate(proxySpec); err != nil {
return nil, err
}
balanceClient, err := netclient.NewHTTPClient(proxySpec, netclient.TransportOptions{})
if err != nil {
return nil, err
}
execProv, err := NewProviderWithProxy(entry, proxySpec)
if err != nil {
return nil, err
}
shell := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, stderr)
sysPrompt, err := cfg.ResolveSystemPromptForRoot(root)
if err != nil {
return nil, err
}
// Output style: fold the selected persona/tone block into the base prompt
// before language/memory/skills append, so a "replace" style (keep-coding
// false) still keeps those. Applied once, into the cache-stable prefix.
if st, ok := outputstyle.Resolve(cfg.Agent.OutputStyle, outputstyle.Dirs()); ok {
sysPrompt = outputstyle.Apply(sysPrompt, st)
}
sysPrompt += "\n\n" + config.UserDecisionPolicy
sysPrompt += "\n\n" + config.LanguagePolicy
if workspaceLine := currentWorkspacePromptLine(root); workspaceLine != "" {
sysPrompt += "\n\n" + workspaceLine
}
if tokenEconomy {
sysPrompt += "\n\n" + tokenEconomyPrompt
} else if tokenDelivery {
sysPrompt += "\n\n" + tokenDeliveryPrompt
}
if cfg.EnvironmentEnabled() {
shellLabel := shell.Kind.String()
if strings.TrimSpace(cfg.Tools.Shell.Path) != "" {
shellLabel = shell.Path
}
envSection := environment.FormatSection(
environment.RunProbesWithOptions(ctx, environment.DefaultProbes(), environment.ProbeOptions{
Overrides: cfg.Environment.Tools,
DenyRoots: []string{root},
// Persist probe results across restarts: the section below sits
// inside the provider-cached prompt prefix, and re-observing
// per boot let transient probe flaps (timeouts, PATH drift)
// rewrite the prefix and cold-start every session's cache.
SnapshotDir: config.CacheDir(),
}),
runtime.GOOS+"/"+runtime.GOARCH,
shellLabel,
cfg.Environment.Tools,
)
if envSection != "" {
sysPrompt += "\n\n" + envSection
}
}
// Persistent memory (REASONIX.md / AGENTS.md hierarchy + auto-memory index)
// folds into the system prompt exactly here, once: it becomes part of the
// durable, cache-stable prefix every turn reuses, so memory costs nothing per
// turn. Mid-session changes never touch this prefix — they ride the
// controller's transient turn-injection and fold in on the next session.
mem := memory.Load(memory.Options{CWD: root, UserDir: config.MemoryUserDir()})
projectChecks := instruction.ExtractHostChecks(mem.Docs)
sysPrompt = memory.Compose(sysPrompt, mem)
// Skills: discover playbooks (built-in + project/custom/global) and fold their
// one-liner index into the same cache-stable prefix — names + descriptions
// only; bodies load on demand via run_skill or "/<name>". Bodies never enter
// the prefix, so the index costs a fixed, small amount per turn.
skillStore := skill.New(skill.Options{
ProjectRoot: root,
CustomPaths: cfg.SkillCustomPaths(),
PluginPaths: cfg.PluginPackageSkillOwners(),
ExcludedPaths: cfg.SkillExcludedPaths(),
DisabledNames: cfg.DisabledSkillNames(),
MaxDepth: cfg.SkillMaxDepth(),
Stderr: opts.Stderr,
})
// Install the static profile filter before building the prompt index and
// dedicated skill tools. The dependency checker is attached once the live
// registry/plugin host has been assembled below.
skillStore.ConfigureInvocationPolicy(string(runtimeProfile), nil)
skills := skillStore.List()
allSkillStore := skill.New(skill.Options{ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard})
allSkills := allSkillStore.List()
if !tokenEconomy {
sysPrompt = skill.ApplyIndex(sysPrompt, skills)
}
reg := tool.NewRegistry()
writeRoots := cfg.WriteRootsForRoot(root)
forbidReadRoots := cfg.ForbidReadRootsForRoot(root)
// managedConfig names the Reasonix-owned config FILES (config.toml,
// compatibility TOMLs, legacy v0.x config.json) the file-writers may repair
// outside the workspace after a fresh per-write human approval. The bash
// OS-sandbox write roots deliberately stay unwidened: config repair goes
// through the approval-gated file tools, not raw shell writes.
managedConfig := builtin.NewManagedConfigPaths(config.ReasonixManagedConfigPaths())
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Network: cfg.Sandbox.Network}
bashSpec.Shell = shell
// The session-data guard blocks agent writes into Reasonix's own session
// stores (they race the app's saves and surface as conflict-copy loops);
// explicit allow_write entries stay a sanctioned escape hatch.
sessionGuard := builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots())
if bashSpec.Mode == "enforce" && !sandbox.Available() {
fmt.Fprintln(stderr, "warning: "+sandbox.UnavailableMessage())
}
if autoShellPrefer(cfg.Tools.Shell.Prefer) && shell.Kind == sandbox.ShellPowerShell {
fmt.Fprintln(stderr, "warning: bash not found on PATH; the shell tool will run commands under Windows PowerShell. Install Git for Windows or WSL to use bash, or set [tools.shell] prefer=\"powershell\" to silence this.")
}
searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr)
bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second
enabledBuiltins := cfg.Tools.Enabled
if tokenEconomy {
enabledBuiltins = tokenEconomyBuiltins(enabledBuiltins)
}
readPathResolver := builtin.NewPathResolver()
addBuiltins(reg, enabledBuiltins, writeRoots, bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, forbidReadRoots, readPathResolver, sessionGuard, managedConfig, opts.FileOverlay, opts.TerminalRunner)
// Use the caller-supplied shared host when set, so controllers for the same
// workspace root reuse running MCP processes (e.g. one CodeGraph daemon
// instead of one per tab). Otherwise construct a private host per controller.
pluginHost := opts.SharedHost
if pluginHost == nil {
pluginHost = plugin.NewHost()
}
// Partition configured plugins by tier so eager can block when explicitly
// requested while every other enabled MCP warms up in the background.
pluginSpecOptions := PluginSpecOptions{
DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
}
autoStartEntries := cfg.AutoStartPlugins()
eagerEntries, bgEntries := partitionByTier(autoStartEntries)
extraSpecs := applyDefaultMCPCallTimeout(
applyPlanModeAllowedMCPToolTrust(applyKnownPluginOverrides(opts.ExtraPlugins, root), cfg.Agent.PlanModeAllowedTools),
pluginSpecOptions.DefaultCallTimeout,
)
onDemandMCPSpecs := map[string]plugin.Spec{}
onDemandMCPNames := []string{}
if tokenEconomy {
for _, spec := range append(PluginSpecsForRootWithOptions(autoStartEntries, root, pluginSpecOptions), extraSpecs...) {
name := strings.TrimSpace(spec.Name)
if name == "" {
continue
}
if _, exists := onDemandMCPSpecs[name]; !exists {
onDemandMCPNames = append(onDemandMCPNames, name)
}
onDemandMCPSpecs[name] = spec
}
eagerEntries, bgEntries = nil, nil
}
trustedMCPServers := planModeTrustedMCPServers(onDemandMCPSpecs)
// Auto-demote: any eager plugin that has been chronically slow (recent
// samples repeatedly hit the blocking startup budget) drops to background
// for this session. The user keeps eager intent, just doesn't pay for it
// on a server that's been misbehaving. A notice surfaces the demotion.
var demoteMessages []string
budget := plugin.DefaultStartupBudget()
kept := eagerEntries[:0]
for _, e := range eagerEntries {
rec := plugin.Recommend(e.Name, budget, 0)
if rec.Demote {
demoteMessages = append(demoteMessages, rec.Reason)
bgEntries = append(bgEntries, e)
continue
}
kept = append(kept, e)
}
eagerEntries = kept
eagerSpecs := PluginSpecsForRootWithOptions(eagerEntries, root, pluginSpecOptions)
bgSpecs := PluginSpecsForRootWithOptions(bgEntries, root, pluginSpecOptions)
if !tokenEconomy {
eagerSpecs = append(eagerSpecs, extraSpecs...)
}
// Apply caller-supplied stderr override to every spec across tiers.
if opts.Stderr != nil {
for i := range eagerSpecs {
eagerSpecs[i].Stderr = opts.Stderr
}
for i := range bgSpecs {
bgSpecs[i].Stderr = opts.Stderr
}
}
// Eager: block until handshake. Failures show up in /mcp.
if len(eagerSpecs) > 0 {
// When using a shared host, reuse already-connected clients and
// add new ones directly to the host instead of creating a separate one.
if opts.SharedHost != nil {
for _, s := range eagerSpecs {
if pluginHost.HasClient(s.Name) {
tools, err := pluginHost.ToolsFor(ctx, s.Name)
if err == nil {
for _, t := range tools {
reg.Add(t)
}
continue
}
}
// Use a bounded per-plugin timeout matching StartAvailable's
// defaultStartTimeout (5s) so a hanging MCP server doesn't
// block the tab boot indefinitely.
addCtx, addCancel := context.WithTimeout(ctx, 5*time.Second)
tools, err := pluginHost.Add(addCtx, s)
addCancel()
if err != nil {
if plugin.IsServerAlreadyConnected(err) || errors.Is(err, plugin.ErrSpawningInFlight) {
// Race: another tab connected the same server between
// HasClient and Add, or is currently spawning it.
// Fetch tools from the existing client, or wait briefly.
tools, err2 := pluginHost.ToolsFor(ctx, s.Name)
if err2 == nil {
for _, t := range tools {
reg.Add(t)
}
continue
}
}
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
Text: "An MCP server failed to start.", Detail: fmt.Sprintf("mcp %s: %v", s.Name, err)})
continue
}
for _, t := range tools {
reg.Add(t)
}
}
} else {
host, ptools := plugin.StartAvailable(ctx, eagerSpecs)
pluginHost = host
for _, t := range ptools {
reg.Add(t)
}
// PhaseB (prompts + resources) runs on the boot ctx — which is the
// controller's session-scoped PluginCtx — so the auxiliary surfaces
// keep streaming in after Start returns without holding up the agent.
go host.StartPhaseB(ctx, sink)
if text, detail, ok := MCPStartupNotice(host.Failures()); ok {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text, Detail: detail})
}
}
}
// Background: register placeholder tools now and kick off the real spawn.
// Everything shares the same pluginHost so /mcp status, hot-add, and Close
// see one cohesive set of servers.
registerBackground := func(specs []plugin.Spec) {
for _, s := range specs {
// Already running on the shared host? Register tools directly.
if pluginHost.HasClient(s.Name) {
tools, err := pluginHost.ToolsFor(ctx, s.Name)
if err == nil {
for _, t := range tools {
reg.Add(t)
}
continue
}
}
if opts.SharedHost != nil {
// Shared host relies on Host's spawn guard to avoid duplicate
// processes across tabs for the same workspace root.
cs, _ := plugin.LoadCachedSchema(s.Name, plugin.SpecFingerprint(s))
for _, t := range plugin.LazyToolset(s, cs, pluginHost, reg, ctx, true) {
reg.Add(t)
}
} else {
cs, _ := plugin.LoadCachedSchema(s.Name, plugin.SpecFingerprint(s))
for _, t := range plugin.LazyToolset(s, cs, pluginHost, reg, ctx, true) {
reg.Add(t)
}
}
}
}
registerBackground(bgSpecs)
for _, msg := range demoteMessages {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg})
}
cleanup := pluginHost.Close
if opts.SharedHost != nil {
// The caller owns the shared host's lifecycle; the controller must not
// close it. A no-op cleanup keeps Controller.Close happy without
// shutting down MCP processes that other controllers still use.
cleanup = func() {}
}
// LSP tools resolve their servers on PATH and spawn lazily on first query, so
// registering them is cheap even when no server is installed (a query then
// returns an install hint). The manager is session-scoped; chain its shutdown
// into the controller's cleanup so servers stop with the session, not the turn.
var lspMgr *lsp.Manager
lspToolsAdded := false
addLSPTools := func() []string {
if lspMgr == nil || lspToolsAdded {
return nil
}
lspToolsAdded = true
return addTools(reg, lsp.Tools(lspMgr))
}
if cfg.LSP.Enabled {
lspMgr = lsp.NewManager(root, LSPSpecs(cfg.LSP))
if !tokenEconomy {
addLSPTools()
}
prev := cleanup
cleanup = func() { prev(); lspMgr.Close() }
}
maxSteps := cfg.Agent.MaxSteps
if opts.MaxSteps > 0 {
maxSteps = opts.MaxSteps
}
subagentStore, err := newSubagentStore(sessionDir)
if err != nil {
return nil, err
}
if subagentStore != nil {
subagentStore.WithDestroyedChecker(jm.IsDestroying)
}
// Permission policy gates every tool call. The headless gate (no Approver)
// resolves ordinary "ask" decisions to allow — preserving `reasonix run`
// autonomy — while deny rules and fresh-human approval tools hard-block.
// Interactive frontends (chat, desktop) swap in an interactive gate later via
// Controller.EnableInteractiveApproval.
// Sub-agents always run headless: they have no UI to answer a prompt, so they
// inherit this same gate.
policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny)
headlessGate := control.NewHeadlessPermissionGate(policy)
// Hooks: load the global settings.json plus the project's (only when trusted —
// project hooks run arbitrary shell commands, so cloning a repo must not
// silently execute them). Non-blocking hook output is surfaced to the user as
// a Notice through the shared sink. The runner fires PreToolUse/PostToolUse in
// the agent loop and PermissionRequest/UserPromptSubmit/Stop at the controller
// boundary.
hooksTrusted := hook.IsTrusted(root, "")
hookRunner := hook.NewRunner(
hook.Load(hook.LoadOptions{ProjectRoot: root, Trusted: hooksTrusted}),
root, nil,
func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) },
)
if hook.ProjectDefinesHooks(root) && !hooksTrusted {
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: "this project defines hooks but they are not trusted — run /hooks trust to enable them"})
}
// The `task` tool spawns sub-agents that reuse the parent's provider and
// tool registry. Wired here after the built-ins / plugins are loaded so
// sub-agents inherit the full tool set (minus `task` itself, to keep
// nesting out of the picture). It registers into the same reg the
// executor uses, so the model surfaces it like any other tool.
resolveSubagentProvider := func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
me := *entry
if strings.TrimSpace(modelRef) != "" {
resolved, ok := cfg.ResolveModel(modelRef)
if !ok {
return nil, nil, 0, fmt.Errorf("unknown model %q", modelRef)
}
me = *resolved
}
if strings.TrimSpace(effort) != "" {
normalized, err := config.NormalizeEffort(&me, effort)
if err != nil {
return nil, nil, 0, err
}
me.Effort = normalized
if me.Kind == "anthropic" && strings.TrimSpace(me.Effort) != "" && strings.TrimSpace(me.Thinking) == "" {
me.Thinking = "adaptive"
}
}
p, err := NewProviderWithProxy(&me, proxySpec)
if err != nil {
return nil, nil, 0, err
}
return p, me.Price, me.ContextWindow, nil
}
subagentIdentity := func(modelRef, effort string) (string, string) {
return subagentEffectiveIdentity(cfg, modelName, entry, modelRef, effort)
}
taskModel := firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel)
taskEffort := firstNonEmpty(cfg.Agent.SubagentEfforts["task"], cfg.Agent.SubagentEffort)
maxSubagentDepth := agent.NormalizeMaxSubagentDepth(cfg.Agent.MaxSubagentDepth)
taskToolAdded := false
readOnlyTaskToolAdded := false
var taskTool *agent.TaskTool
newTaskTool := func() *agent.TaskTool {
return agent.NewTaskTool(execProv, entry.Price, reg, maxSteps,
entry.ContextWindow, cfg.Agent.RecentKeep, cfg.Agent.SoftCompactRatio, cfg.Agent.ToolResultSnipRatio, cfg.Agent.CompactRatio, cfg.Agent.CompactForceRatio,
cfg.Agent.Temperature, config.ArchiveDir(), "", headlessGate,
keepPolicy,
taskModel, taskEffort, resolveSubagentProvider).
WithTranscripts(subagentStore, root, modelName, entry.Effort).
WithTranscriptIdentityResolver(subagentIdentity).
WithMaxSubagentDepth(maxSubagentDepth).
WithDeliveryProfile(tokenDelivery)
}
addTaskTool := func() string {
if taskToolAdded {
return "task tool is already enabled."
}
taskToolAdded = true
if taskTool == nil {
taskTool = newTaskTool()
}
reg.Add(taskTool)
reg.Add(agent.NewParallelTasksTool(taskTool, reg))
return "enabled task."
}
addReadOnlyTaskTool := func() string {
if readOnlyTaskToolAdded {
return "read_only_task tool is already enabled."
}
readOnlyTaskToolAdded = true
if taskTool == nil {
taskTool = newTaskTool()
}
reg.Add(agent.NewReadOnlyTaskTool(taskTool))
return "enabled read_only_task."
}
if !tokenEconomy {
addTaskTool()
addReadOnlyTaskTool()
}
// The `memory` tool searches/reads saved facts on demand; `remember` persists
// durable facts to the project's auto-memory store; `forget` prunes ones that
// turn out wrong. The saved index loads into the prefix on the next session.
reg.Add(history.NewTool(history.Options{SessionDir: sessionDir, GlobalSessionDir: config.SessionDir(), ArchiveDir: config.ArchiveDir()}))
// Session history tools let the AI discover and read past conversations.
// `list_sessions` returns all saved session files; `read_session` loads one
// and renders the full conversation as readable text.
reg.Add(sessiontool.NewListSessionsTool(sessionDir))
reg.Add(sessiontool.NewReadSessionTool(sessionDir))
reg.Add(memory.NewRecallTool(mem.Store))
reg.Add(memory.NewRememberTool(mem.Store))
reg.Add(memory.NewForgetTool(mem.Store))
// The `ask` tool puts structured multiple-choice questions to the user. It
// reaches them through the Asker on the call context, which interactive
// frontends wire to the controller (EnableInteractiveApproval); a headless run
// has none, so ask resolves to "decide for yourself".
reg.Add(agent.NewAskTool())
// Skill tools: read_only_skill is a narrow plan-mode-safe entry point; the
// full skills source adds run_skill / install_skill plus the dedicated
// subagent wrappers (explore / research / review / security_review). Read-only
// subagent skills run ephemerally with the same registry boundary as
// read_only_task, so they cannot write, install, mutate memory, resume/fork
// transcripts, or delegate further.
//
// subagentSkillOptions is the single construction point for skill sub-agent
// run options, so the read-only and writer-capable runners cannot drift on
// compaction or language settings — add new fields here, not per runner.
subagentSkillOptions := func(sctx context.Context, steps int, price *provider.Pricing, ctxWin, childDepth int) agent.Options {
return agent.Options{
MaxSteps: steps,
Temperature: cfg.Agent.Temperature,
Pricing: price,
UsageSource: event.UsageSourceSubagent,
Gate: headlessGate,
ContextWindow: ctxWin,
RecentKeep: cfg.Agent.RecentKeep,
SoftCompactRatio: cfg.Agent.SoftCompactRatio,
ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio,
CompactRatio: cfg.Agent.CompactRatio,
CompactForceRatio: cfg.Agent.CompactForceRatio,
ArchiveDir: config.ArchiveDir(),
KeepPolicy: keepPolicy,
ResponseLanguage: agent.ResponseLanguageFromContext(sctx),
ReasoningLanguage: agent.ReasoningLanguageFromContext(sctx),
SubagentDepth: childDepth,
MaxSubagentDepth: maxSubagentDepth,
DeliveryProfile: tokenDelivery,
}
}
readOnlySkillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
if strings.TrimSpace(runOpts.ContinueFrom) != "" || strings.TrimSpace(runOpts.ForkFrom) != "" {
return "", fmt.Errorf("read_only_skill does not support continue_from/fork_from")
}
sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg))
prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow
modelRef := subagentModelRef(cfg, sk)
effortRef := subagentEffortRef(cfg, sk)
if modelRef != "" || effortRef != "" {
p, pr, cw, err := resolveSubagentProvider(modelRef, effortRef)
if err != nil {
return "", fmt.Errorf("read-only subagent skill %q profile: %w", sk.Name, err)
}
prov, price, ctxWin = p, pr, cw
}
childDepth := agent.SubagentDepth(sctx) + 1
if childDepth > maxSubagentDepth {
return "", fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxSubagentDepth)
}
subReg := agent.ReadOnlySubagentToolRegistryForDepth(reg, sk.AllowedTools, childDepth, maxSubagentDepth)
if subReg.Len() == 0 {
return "", fmt.Errorf("read_only_skill: skill %q has no read-only tools available", sk.Name)
}
switch sk.Name {
case "review", "security-review", "security_review":
agent.AttachReviewReportTool(subReg)
}
steps := maxSteps
if steps > 0 {
if steps /= 2; steps < 5 {
steps = 5
}
}
sysPrompt := agent.DefaultReadOnlyTaskSystemPrompt + "\n\nSkill instructions:\n" + sk.Body
runOptions := subagentSkillOptions(sctx, steps, price, ctxWin, childDepth)
// Delivery risk gates consume typed reports; outside Delivery a casual
// /review run may finish with prose only.
if runOptions.DeliveryProfile {
runOptions.RequireReviewReportKind = agent.ReviewReportKindForSkill(sk.Name)
}
return agent.RunSubAgentWithSession(sctx, prov, subReg, agent.NewSession(sysPrompt), task,
runOptions, agent.NestedSink(sctx, event.Discard))
}
// Writer-capable subagent skills reuse the sub-agent machinery via this
// runner: an isolated loop with the skill body as system prompt, a tool set
// scoped to the skill's allowed-tools (minus recursive meta-tools), optional
// per-skill model, and resumable transcripts when the parent session supports
// them. Its tool activity nests under the invoking call, like `task`.
skillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg))
prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow
modelRef := subagentModelRef(cfg, sk)
effortRef := subagentEffortRef(cfg, sk)
if modelRef != "" || effortRef != "" {
p, pr, cw, err := resolveSubagentProvider(modelRef, effortRef)
if err != nil {
return "", fmt.Errorf("subagent skill %q profile: %w", sk.Name, err)
}
prov, price, ctxWin = p, pr, cw
}
childDepth := agent.SubagentDepth(sctx) + 1
if childDepth > maxSubagentDepth {
return "", fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxSubagentDepth)
}
// A read-only skill (builtin review/security-review, or frontmatter
// `read-only: true`) gets its promise enforced at the tool boundary:
// writer tools are stripped and bash runs under the plan-mode safe
// command policy. Transcripts recorded against the writer-capable
// registry stop matching on continue_from (schema-hash check reports
// the mismatch).
var subReg *tool.Registry
if sk.ReadOnly {
subReg = agent.ReadOnlySubagentToolRegistryForDepth(reg, sk.AllowedTools, childDepth, maxSubagentDepth)
} else {
subReg = agent.SubagentToolRegistryForDepth(reg, sk.AllowedTools, childDepth, maxSubagentDepth)
}
// Delivery risk gates require structured review_report from review
// subagents only — never expose it on the parent tool surface.
switch sk.Name {
case "review", "security-review", "security_review":
agent.AttachReviewReportTool(subReg)
}
continueFrom := strings.TrimSpace(runOpts.ContinueFrom)
legacyForkFrom := strings.TrimSpace(runOpts.ForkFrom)
if continueFrom != "" && legacyForkFrom != "" {
return "", fmt.Errorf("continue_from and fork_from are mutually exclusive; pass only continue_from")
}
parentID, _, _, _ := agent.CallContext(sctx)
if runOpts.HostInitiated {
parentID = ""
}
parentSession := agent.ParentSession(sctx)
var run *agent.SubagentRun
if subagentStore == nil || parentSession == "" {
// Headless runs (e.g. `reasonix run`) have no persistent session to
// own a transcript. Run the skill sub-agent ephemerally, as before
// persisted transcripts existed, instead of failing. Continuation needs
// a persisted owner, so it errors here.
if continueFrom != "" || legacyForkFrom != "" {
return "", fmt.Errorf("subagent continuation requires a persisted session; none is active in this run")
}
run = agent.EphemeralSubagentRun(sk.Body)
} else {
identityModel, identityEffort := subagentIdentity(modelRef, effortRef)
spec := agent.SubagentSpec{
Kind: "skill",
Name: sk.Name,
WorkspaceRoot: root,
ParentSession: parentSession,
ParentToolCallID: parentID,
SystemPrompt: sk.Body,
Registry: subReg,
Model: identityModel,
Effort: identityEffort,
}
var prepErr error
if continueFrom != "" {
run, prepErr = subagentStore.PrepareContinue(continueFrom, spec)
} else if legacyForkFrom != "" {
run, prepErr = subagentStore.PrepareLegacyForkFrom(legacyForkFrom, spec)
} else {
run, prepErr = subagentStore.PrepareFresh(spec)
}
if prepErr != nil {
return "", prepErr
}
}
defer run.Release()
steps := maxSteps
if steps > 0 {
if steps /= 2; steps < 5 {
steps = 5
}
}
runOptions := subagentSkillOptions(sctx, steps, price, ctxWin, childDepth)
// Delivery risk gates consume typed reports; outside Delivery a casual
// /review run may finish with prose only.
if runOptions.DeliveryProfile {
runOptions.RequireReviewReportKind = agent.ReviewReportKindForSkill(sk.Name)
}
answer, err := agent.RunSubAgentWithSession(sctx, prov, subReg, run.Session, task,
runOptions, agent.NestedSink(sctx, event.Discard))
if err != nil {
return "", errors.Join(err, subagentStore.SaveFailed(run))
}
if err := subagentStore.SaveCompleted(run); err != nil {
return "", errors.Join(err, subagentStore.SaveFailed(run))
}
return agent.FormatSubagentRunResult(answer, run, false), nil
}
skillProfile := func(sk skill.Skill) *event.Profile {
model, effort := subagentModelRef(cfg, sk), subagentEffortRef(cfg, sk)
if model == "" && effort == "" {
return nil
}
return &event.Profile{Model: model, Effort: effort}
}
// Custom slash commands (.reasonix/commands + user dir). Best-effort: a malformed
// file is skipped, and a load error never blocks the session.
cmds, _ := command.LoadRoots(config.CommandRootsForRoot(root)...)
addSlashCommandTool := func(includeSkills bool) {
// Expose loaded slash commands to the model via slash_command. In economy
// mode skills join this list only after the skills source is enabled.
var slashEntries []command.SlashEntry
if includeSkills {
for _, sk := range skillStore.SlashList() {
sk := sk
slashEntries = append(slashEntries, command.SlashEntry{
Name: sk.SlashName(),
Description: sk.Description,
Render: func(args []string) string { return skill.Render(sk, strings.Join(args, " ")) },
})
}
}
for _, cmd := range cmds {
if cmd.Hidden {
continue
}
cmd := cmd
slashEntries = append(slashEntries, command.SlashEntry{
Name: cmd.Name,
Description: cmd.Description,
ArgHint: cmd.ArgHint,
Render: func(args []string) string { return cmd.Render(args) },
})
}
reg.Add(command.NewSlashCommandTool(slashEntries))
}
installSourceAdded := false
addInstallSourceTool := func() string {
if installSourceAdded {
return "install_source is already enabled."
}
installSourceAdded = true
reg.Add(installsource.NewTool(installsource.Options{
ProjectRoot: root,
HTTPClient: balanceClient,
ConnectMCP: func(e config.PluginEntry) (installsource.MCPConnectResult, error) {
spec := pluginSpecFromEntryWithOptions(e, root, pluginSpecOptions)
if opts.Stderr != nil {
spec.Stderr = opts.Stderr
}
tools, err := pluginHost.Add(ctx, spec)
if err != nil {
return installsource.MCPConnectResult{}, err
}
reg.RemovePrefix(plugin.ToolPrefix(spec.Name))
for _, t := range tools {
reg.Add(t)
}
// Disconnect closes the server and drops its namespaced tools.
// Used by the install_source rollback path when SaveTo fails.
disconnect := func() {
if prefix, ok := pluginHost.Remove(spec.Name); ok {
reg.RemovePrefix(prefix)
}
}
return installsource.MCPConnectResult{
ToolCount: len(tools),
Disconnect: disconnect,
}, nil
},
OnDisconnect: func(serverName string) bool {
if prefix, ok := pluginHost.Remove(serverName); ok {
reg.RemovePrefix(prefix)
return true
}
return false
},
}))
return "enabled install_source."
}
readOnlySkillToolsAdded := false
addReadOnlySkillTools := func() string {
if readOnlySkillToolsAdded {
return "read_only_skill tool is already enabled.\n\n" + skill.ReadOnlyIndexBlock(skills)
}
readOnlySkillToolsAdded = true
reg.Add(skill.NewReadOnlySkillTool(skillStore, readOnlySkillRunner, skillProfile))
return "enabled read_only_skill. Use read_only_skill for inline skills or read-only subagent skills on the next model request.\n\n" + skill.ReadOnlyIndexBlock(skills)
}
skillToolsAdded := false
addSkillTools := func() string {
if skillToolsAdded {
return "skills are already enabled.\n\n" + skill.IndexBlock(skills)
}
skillToolsAdded = true
addReadOnlySkillTools()
reg.Add(skill.NewRunSkillTool(skillStore, skillRunner, skillProfile))
reg.Add(skill.NewReadSkillTool(skillStore))
reg.Add(skill.NewInstallSkillTool(skillStore, nil))
for _, t := range skill.BuiltinSubagentTools(skillStore, skillRunner, skillProfile) {
reg.Add(t)
}
addSlashCommandTool(true)
return "enabled skills. Use run_skill/read_skill/read_only_skill or the dedicated skill tools on the next model request.\n\n" + skill.IndexBlock(skills)
}
if tokenEconomy {
addSlashCommandTool(false)
} else {
addInstallSourceTool()
addSkillTools()
}
if tokenEconomy {
reg.Add(&toolSourceConnector{
skills: func(context.Context) (string, error) {
return addSkillTools(), nil
},
task: func(context.Context) (string, error) {
return addTaskTool(), nil
},
readOnlyTask: func(context.Context) (string, error) {
return addReadOnlyTaskTool(), nil
},
readOnlySkill: func(context.Context) (string, error) {
return addReadOnlySkillTools(), nil
},
install: func(context.Context) (string, error) {
return addInstallSourceTool(), nil
},
webFetch: func(context.Context) (string, error) {
if !builtinToolEnabled(cfg.Tools.Enabled, "web_fetch") {
return "web_fetch is disabled by [tools].enabled.", nil
}
names := addTools(reg, builtin.Workspace{
Dir: root,
WriteRoots: writeRoots,
Bash: bashSpec,
BashTimeout: bashTimeout,
Search: searchSpec,
ProxySpec: proxySpec,
}.Tools("web_fetch"))
if len(names) == 0 {
return "web_fetch is already enabled or unavailable.", nil
}
return "enabled " + strings.Join(names, ", ") + ".", nil
},
lsp: func(context.Context) (string, error) {
if lspMgr == nil {
return "", fmt.Errorf("LSP is disabled in config")
}
names := addLSPTools()
if len(names) == 0 {
return "LSP tools are already enabled.", nil
}
return "enabled " + strings.Join(names, ", ") + ".", nil
},
mcp: func(_ context.Context, name string) (string, error) {
spec, ok := onDemandMCPSpecs[name]
if !ok {
return "", fmt.Errorf("no configured MCP server named %q", name)
}
if opts.Stderr != nil {
spec.Stderr = opts.Stderr
}
tools, err := pluginHost.Add(ctx, spec)
if err != nil {
// On a shared host the server may already be connected
// (e.g. another tab started it). Fall back to fetching
// its tools from the existing client.
if errors.Is(err, plugin.ErrServerAlreadyConnected) || errors.Is(err, plugin.ErrSpawningInFlight) {
tools, err2 := pluginHost.ToolsFor(ctx, spec.Name)
if err2 != nil {
return "", err2
}
reg.RemovePrefix(plugin.ToolPrefix(spec.Name))
names := addTools(reg, tools)
if len(names) == 0 {
return fmt.Sprintf("MCP server %q connected but exposed no tools.", spec.Name), nil
}
return fmt.Sprintf("enabled MCP server %q tools: %s.", spec.Name, strings.Join(names, ", ")), nil
}
return "", err
}
reg.RemovePrefix(plugin.ToolPrefix(spec.Name))
names := addTools(reg, tools)
if len(names) == 0 {
return fmt.Sprintf("MCP server %q connected but exposed no tools.", spec.Name), nil
}
return fmt.Sprintf("enabled MCP server %q tools: %s.", spec.Name, strings.Join(names, ", ")), nil
},
mcpNames: onDemandMCPNames,
planModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
planModeTrustedMCPServer: trustedMCPServers,
})
}
// Delivery-only stable capability proxy. Registered before agent.New so the
// tool schema is part of the Delivery cache prefix and never changes when
// on-demand MCP servers connect through the proxy.
var capLedger *capability.Ledger
var capAudit *capability.Audit
capSpecs := PluginSpecsForRootWithOptions(cfg.Plugins, root, pluginSpecOptions)
cachedTools, cacheHashOK := capability.LoadCachedToolsForSpecs(capSpecs)
var capProxy *agent.UseCapabilityTool
if tokenDelivery {
capLedger = capability.NewLedger()
capAudit = &capability.Audit{}
failed := map[string]string{}
if pluginHost != nil {
for _, f := range pluginHost.Failures() {
failed[f.Name] = f.Error
}
}
// The proxy and the catalog share the boot-converted specs (env
// expansion, workspace overrides, timeouts, trusted read-only tools) —
// every configured server, including auto_start=false, is proxy-callable.
catalogFn := func() capability.Catalog {
conn := map[string]bool{}
if pluginHost != nil {
for _, n := range pluginHost.ServerNames() {
conn[n] = true
}
}
catOpts := capability.CatalogOptions{
Tools: reg.ContractEntries(),
Skills: skillStore.List(),
Plugins: cfg.Plugins,
Profile: capability.ProfileDelivery,
Connected: conn,
Failed: failed,
CachedTools: cachedTools,
CacheHashOK: cacheHashOK,
}
// Live proxy-observed tools keep mcp-tool entries routable after an
// on-demand connect (proxied tools never enter the registry).
if capProxy != nil {
catOpts.ProxyTools = capProxy.ConnectedProxyTools()
}
return capability.BuildCatalog(catOpts)
}
// ctx is the session-scoped boot context (the lifetime PluginCtx hands
// the controller): on-demand MCP children must survive the tool call
// that starts them and die with the session, not a resolve timeout.
capProxy = agent.NewUseCapabilityTool(ctx, pluginHost, capSpecs, reg, capLedger, capAudit, catalogFn)
reg.Add(capProxy)
}
skillStore.ConfigureInvocationPolicy(string(runtimeProfile), func(requires []string) []string {
connected := map[string]bool{}
failed := map[string]string{}
if pluginHost != nil {
for _, name := range pluginHost.ServerNames() {
connected[name] = true
}
for _, failure := range pluginHost.Failures() {
failed[failure.Name] = failure.Error
}
}
var proxyTools map[string][]plugin.CachedTool
if capProxy != nil {
proxyTools = capProxy.ConnectedProxyTools()
}
catalog := capability.BuildCatalog(capability.CatalogOptions{
Tools: reg.ContractEntries(),
Skills: skillStore.List(),
Plugins: cfg.Plugins,
Profile: runtimeProfile,
Connected: connected,
Failed: failed,
CachedTools: cachedTools,
CacheHashOK: cacheHashOK,
ProxyTools: proxyTools,
})
_, missing := catalog.RequiresReady(requires)
return missing
})
execSess := agent.NewSession(sysPrompt)
var memCompiler *memorycompiler.Runtime
if cfg.MemoryCompilerEnabled() {
memCompiler = memorycompiler.New(config.MemoryCompilerDir(root))
}
executor := agent.New(execProv, reg, execSess, agent.Options{
MaxSteps: maxSteps,
Temperature: cfg.Agent.Temperature,
Pricing: entry.Price,
Gate: headlessGate,
Hooks: hookRunner,
Jobs: jm,
ProjectChecks: projectChecks,
DeliveryProfile: tokenDelivery,
CapabilityLedger: capLedger,
CapabilityAudit: capAudit,
ContextWindow: entry.ContextWindow,
SoftCompactRatio: cfg.Agent.SoftCompactRatio,
ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio,
CompactRatio: cfg.Agent.CompactRatio,
CompactForceRatio: cfg.Agent.CompactForceRatio,
RecentKeep: cfg.Agent.RecentKeep,
ArchiveDir: config.ArchiveDir(),
KeepPolicy: keepPolicy,
ReasoningLanguage: cfg.ReasoningLanguage(),
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
SubagentDepth: 0,
MaxSubagentDepth: maxSubagentDepth,
MemoryCompiler: memCompiler,
MemoryCompilerVerbosity: cfg.MemoryCompilerVerbosity(),
UseMemoryCompilerLLMClassification: strings.TrimSpace(os.Getenv("REASONIX_MEMORY_COMPILER_LLM_CLASSIFICATION")) == "true",
}, sink)
var runner agent.Runner = executor
label := entry.Model
var classifier *control.ProviderAutoPlanClassifier
if !tokenEconomy && !strings.EqualFold(strings.TrimSpace(cfg.Agent.AutoPlan), "off") && cfg.Agent.AutoPlanClassifier != "" {
cm := cfg.Agent.AutoPlanClassifier
ce, ok := cfg.ResolveModel(cm)
if !ok {
return nil, fmt.Errorf("auto_plan_classifier %q is not a configured provider", cm)
}
classifierProv, err := NewProviderWithProxy(ce, proxySpec)
if err != nil {
return nil, fmt.Errorf("auto_plan_classifier %q: %w", cm, err)
}
classifier = control.NewBillableProviderAutoPlanClassifier(classifierProv, ce.Price, sink)
}
// Two-model collaboration: a distinct planner_model wraps the executor in a
// Coordinator with its own session, kept separate for cache stability. The
// planner gets the same standing memory context and a filtered read-only
// research tool set, so it can inspect rules/code without side effects.
if pm := cfg.Agent.PlannerModel; pm != "" && !tokenEconomy {
pe, ok := cfg.ResolveModel(pm)
if !ok {
return nil, fmt.Errorf("planner_model %q is not a configured provider", pm)
}
if pe.Model != entry.Model {
plannerProv, err := NewProviderWithProxy(pe, proxySpec)
if err != nil {
return nil, fmt.Errorf("planner %q: %w", pm, err)
}
plannerSess := agent.NewSession(agent.PlannerPromptWithContext(mem.Block()))
plannerTools := agent.PlannerToolRegistry(reg)
runner = agent.NewCoordinator(plannerProv, plannerSess, pe.Price, plannerTools, agent.Options{
MaxSteps: cfg.Agent.PlannerMaxSteps,
MaxStepsKey: "agent.planner_max_steps",
Gate: headlessGate,
ContextWindow: pe.ContextWindow,
SoftCompactRatio: cfg.Agent.SoftCompactRatio,
ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio,
CompactRatio: cfg.Agent.CompactRatio,
CompactForceRatio: cfg.Agent.CompactForceRatio,
RecentKeep: cfg.Agent.RecentKeep,
ArchiveDir: config.ArchiveDir(),
KeepPolicy: keepPolicy,
ReasoningLanguage: cfg.ReasoningLanguage(),
PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
}, executor, cfg.Agent.Temperature, sink, control.NewPlannerGate(classifier))
label = entry.Model + " + planner " + pe.Model
}
}
ctrlOpts := control.Options{
Runner: runner,
Executor: executor,
Sink: sink,
Policy: policy,
Label: label,
ModelRef: modelRef,
SystemPrompt: sysPrompt,
SessionDir: sessionDir,
Host: pluginHost,
Commands: cmds,
Skills: skills,
AllSkills: allSkills,
SkillStore: skillStore,
AllSkillStore: allSkillStore,
SkillRunner: skillRunner,
ReadOnlySkillRunner: readOnlySkillRunner,
SkillProfile: skillProfile,
Hooks: hookRunner,
Memory: mem,
Cleanup: cleanup,
BalanceURL: entry.BalanceURL,
BalanceKey: entry.APIKey(),
BalanceClient: balanceClient,
Jobs: jm,
Registry: reg,
PluginCtx: ctx,
WorkspaceRoot: root,
ExternalFolderToolRefs: readPathResolver,
AutoPlan: cfg.Agent.AutoPlan,
ResponseLanguage: cfg.ResponseLanguage(),
ReasoningLanguage: cfg.ReasoningLanguage(),
DisableColdResumePrune: !cfg.ColdResumePruneEnabled(),
Shell: shell,
PlanModeAllowedTools: cfg.Agent.PlanModeAllowedTools,
ApprovalTimeout: opts.ApprovalTimeout,
RuntimeProfile: runtimeProfile,
OnRemember: func(rule string) control.RememberResult {
return rememberPermissionRule(root, rule)
},
OnRememberMCPReadOnlyTrust: func(serverName, rawToolName string) control.MCPReadOnlyTrustResult {
return rememberMCPReadOnlyTrust(root, serverName, rawToolName)
},
OnRememberPlanModeReadOnlyCommand: func(prefix string) control.PlanModeReadOnlyCommandTrustResult {
return rememberPlanModeReadOnlyCommand(root, prefix)
},
SessionRecoveryMeta: opts.SessionRecoveryMeta,
OnSessionRecovered: opts.OnSessionRecovered,
}
// Guardian: when guardian_model is configured, spawn an LLM safety reviewer
// that can auto-allow safe Ask decisions and annotate risky ones before
// escalating to the human approval prompt.
if guardianModel := cfg.Agent.GuardianModel; guardianModel != "" {
ge, ok := cfg.ResolveModel(guardianModel)
if !ok {
slog.Warn("guardian model is not a configured provider — guardian disabled", "model", guardianModel)
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because its model was not found.", Detail: fmt.Sprintf("guardian_model %q not found — guardian disabled", guardianModel)})
} else {
pProv, err := NewProviderWithProxy(ge, proxySpec)
if err != nil {
slog.Warn("guardian provider construction failed — guardian disabled", "model", guardianModel, "err", err)
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because it could not start.", Detail: fmt.Sprintf("guardian construction failed: %v — guardian disabled", err)})
} else {
guardianReg := agent.FilterReadOnlyRegistry(reg, agent.SubagentMetaTools()...)
ctrlOpts.Guardian = guardian.NewSession(pProv, guardianReg, guardian.PolicyPrompt(), guardianModel, cfg.Agent.GuardianTemperature, ge.Price, sink)
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("guardian enabled · model=%s", ge.Model)})
}
}
}
if classifier != nil {
ctrlOpts.Classifier = classifier
}
ctrl := control.New(ctrlOpts)
if tokenDelivery {
var router *capability.SemanticRouter
// Prefer agent.subagent_models["capability-router"] when configured.
if modelRef := strings.TrimSpace(cfg.Agent.SubagentModels["capability-router"]); modelRef != "" {
if p, price, _, err := resolveSubagentProvider(modelRef, strings.TrimSpace(cfg.Agent.SubagentEfforts["capability-router"])); err == nil && p != nil {
router = &capability.SemanticRouter{Provider: p, Sink: sink, Model: modelRef, Pricing: price, Audit: capAudit}
}
}
if router == nil {
// Fallback to the executor's provider — and its pricing, so router
// usage events never display as zero-cost.
router = &capability.SemanticRouter{Provider: execProv, Sink: sink, Pricing: entry.Price, Audit: capAudit}
}
ctrl.WireCapabilityRouting(cfg.Plugins, capSpecs, router, capAudit)
} else if tokenEconomy {
ctrl.WireCapabilityRouting(cfg.Plugins, capSpecs, nil, nil)
}
return ctrl, nil
}
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
path := rememberPermissionConfigPath(workspaceRoot)
edit := config.LoadForEdit(path)
result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path}
if coveredBy := coveredPermissionRule(edit.Permissions.Allow, result.Rule); coveredBy != "" {
result.CoveredBy = coveredBy
return result
}
edit.Permissions.Allow = pruneCoveredPermissionRules(edit.Permissions.Allow, result.Rule)
if err := edit.AddPermissionRule("allow", rule); err != nil {
slog.Warn("persist permission rule", "rule", rule, "err", err)
result.Err = err
return result
}
if err := config.WritePermissionsSection(path, edit.Permissions.Allow); err != nil {
slog.Warn("save config after permission rule", "err", err)
result.Err = err
return result
}
result.Saved = true
return result
}
func rememberPermissionConfigPath(workspaceRoot string) string {
workspaceRoot = strings.TrimSpace(workspaceRoot)
if workspaceRoot != "" {
return filepath.Join(workspaceRoot, "reasonix.toml")
}
path := config.SourcePath()
if path == "" {
path = "reasonix.toml" // match Config.Save() fallback
}
return path
}
func rememberMCPReadOnlyTrust(workspaceRoot, serverName, rawToolName string) control.MCPReadOnlyTrustResult {
serverName = strings.TrimSpace(serverName)
rawToolName = strings.TrimSpace(rawToolName)
result := control.MCPReadOnlyTrustResult{Server: serverName, Tool: rawToolName}
_, changed, path, err := config.TrustPluginReadOnlyToolInSourceForRoot(workspaceRoot, serverName, rawToolName)
result.Path = path
if err != nil {
slog.Warn("persist MCP read-only trust", "server", serverName, "tool", rawToolName, "err", err)
result.Err = err
return result
}
if changed {
result.Saved = true
return result
}
result.CoveredBy = rawToolName
return result
}
func rememberPlanModeReadOnlyCommand(workspaceRoot, prefix string) control.PlanModeReadOnlyCommandTrustResult {
prefix = strings.TrimSpace(prefix)
path := rememberPermissionConfigPath(workspaceRoot)
edit := config.LoadForEdit(path)
result := control.PlanModeReadOnlyCommandTrustResult{Prefix: prefix, Path: path}
if prefix == "" {
result.Err = fmt.Errorf("empty plan-mode read-only command prefix")
return result
}
if coveredBy := coveredPlanModeReadOnlyCommand(edit.Agent.PlanModeReadOnlyCommands, prefix); coveredBy != "" {
result.CoveredBy = coveredBy
return result
}
edit.Agent.PlanModeReadOnlyCommands = append(edit.Agent.PlanModeReadOnlyCommands, prefix)
if err := edit.SaveTo(path); err != nil {
slog.Warn("persist plan-mode read-only command trust", "prefix", prefix, "err", err)
result.Err = err
return result
}
result.Saved = true
return result
}
func coveredPlanModeReadOnlyCommand(existing []string, candidate string) string {
candidateFields := strings.Fields(strings.TrimSpace(candidate))
if len(candidateFields) == 0 {
return ""
}
for _, item := range existing {
itemFields := strings.Fields(strings.TrimSpace(item))
if len(itemFields) == 0 || len(itemFields) > len(candidateFields) {
continue
}
matches := true
for i, field := range itemFields {
if candidateFields[i] != field {
matches = false
break
}
}
if matches {
return strings.Join(itemFields, " ")
}
}
return ""
}
func coveredPermissionRule(rules []string, rule string) string {
for _, existing := range rules {
if permission.RuleCoversString(existing, rule) {
return strings.TrimSpace(existing)
}
}
return ""
}
func pruneCoveredPermissionRules(rules []string, rule string) []string {
out := rules[:0]
for _, existing := range rules {
if strings.TrimSpace(existing) == "" || permission.RuleCoversString(rule, existing) {
continue
}
out = append(out, existing)
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func subagentModelRef(cfg *config.Config, sk skill.Skill) string {
if cfg != nil {
for _, key := range SubagentModelKeys(sk.Name) {
if m := strings.TrimSpace(cfg.Agent.SubagentModels[key]); m != "" {
return m
}
}
}
if m := strings.TrimSpace(sk.Model); m != "" {
return m
}
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.Agent.SubagentModel)
}
func subagentEffortRef(cfg *config.Config, sk skill.Skill) string {
if cfg != nil {
for _, key := range SubagentModelKeys(sk.Name) {
if e := strings.TrimSpace(cfg.Agent.SubagentEfforts[key]); e != "" {
return e
}
}
}
if e := strings.TrimSpace(sk.Effort); e != "" {
return e
}
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.Agent.SubagentEffort)
}
// SubagentModelKeys returns the cfg.Agent.SubagentModels/SubagentEfforts map
// keys that resolve for a subagent name, in precedence order: the exact name
// first, then its underscore/hyphen alias variants (the dedicated tool
// security_review dispatches the skill security-review, so either spelling in
// config must reach it). Any surface that reads OR clears these maps must
// iterate this same key set — an exact-key delete leaves an alias entry
// silently active.
func SubagentModelKeys(name string) []string {
name = strings.TrimSpace(name)
if name == "" {
return nil
}
keys := []string{name}
for _, alias := range []string{
strings.ReplaceAll(name, "-", "_"),
strings.ReplaceAll(name, "_", "-"),
} {
if alias == "" {
continue
}
seen := false
for _, key := range keys {
if key == alias {
seen = true
break
}
}
if !seen {
keys = append(keys, alias)
}
}
return keys
}
func currentWorkspacePromptLine(root string) string {
if root == "" {
return ""
}
return "Current workspace: " + strconv.Quote(root)
}
func resolveWorkspaceRoot(explicit string) string {
if explicit != "" {
return explicit
}
wd, err := os.Getwd()
if err != nil {
return ""
}
if root, ok := nearestGitRoot(wd); ok {
return root
}
return wd
}
func nearestGitRoot(start string) (string, bool) {
dir, err := filepath.Abs(start)
if err != nil {
dir = filepath.Clean(start)
}
for {
if isGitMarker(filepath.Join(dir, ".git")) {
return dir, true
}
next := filepath.Dir(dir)
if next == dir {
return "", false
}
dir = next
}
}
func isGitMarker(path string) bool {
fi, err := os.Stat(path)
return err == nil && (fi.IsDir() || fi.Mode().IsRegular())
}
func newSubagentStore(sessionDir string) (*agent.SubagentStore, error) {
sessionDir = strings.TrimSpace(sessionDir)
if sessionDir == "" {
return nil, nil
}
store := agent.NewSubagentStore(filepath.Join(sessionDir, "subagents"))
if _, err := store.CleanupStaleRunning(); err != nil {
return nil, fmt.Errorf("cleanup stale subagents: %w", err)
}
return store, nil
}
func subagentEffectiveIdentity(cfg *config.Config, baseModelRef string, base *config.ProviderEntry, modelRef, effort string) (string, string) {
var entry config.ProviderEntry
if base != nil {
entry = *base
}
ref := strings.TrimSpace(modelRef)
if ref == "" {
ref = strings.TrimSpace(baseModelRef)
}
if cfg != nil && ref != "" {
if resolved, ok := cfg.ResolveModel(ref); ok {
entry = *resolved
} else if strings.TrimSpace(modelRef) != "" {
entry.Model = ref
}
} else if strings.TrimSpace(modelRef) != "" {
entry.Model = strings.TrimSpace(modelRef)
}
if rawEffort := strings.TrimSpace(effort); rawEffort != "" {
if normalized, err := config.NormalizeEffort(&entry, rawEffort); err == nil {
entry.Effort = normalized
} else {
entry.Effort = rawEffort
}
}
modelID := strings.TrimSpace(entry.Name)
model := strings.TrimSpace(entry.Model)
if modelID != "" && model != "" {
modelID += "/" + model
} else if model != "" {
modelID = model
} else if modelID == "" {
modelID = ref
}
return modelID, strings.TrimSpace(config.EffectiveEffort(&entry))
}
// NewProvider builds a provider.Provider from a configured entry. Exported so
// custom assemblers (e.g. the ACP per-session factory) can reuse it without
// going through the full Build.
func NewProvider(e *config.ProviderEntry) (provider.Provider, error) {
return NewProviderWithProxy(e, netclient.ProxySpec{Mode: netclient.ModeAuto})
}
// NewProviderWithProxy builds a provider.Provider with the configured ordinary
// network proxy settings.
func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (provider.Provider, error) {
return provider.New(e.Kind, provider.Config{
Name: e.Name,
BaseURL: e.BaseURL,
Model: e.Model,
APIKey: e.APIKey(),
// Pass the key's env var so auth failures can name where to fix it, plus
// provider-kind-specific knobs. EffectiveEffort applies a configured
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": e.APIKeySourceLabel(),
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"chat_url": e.ChatURL,
"headers": e.Headers,
"extra_body": e.ExtraBody,
"auth_header": e.AuthHeader,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
},
})
}
// addBuiltins adds enabled built-in tools to reg. An empty list means all of
// them. writeRoots confines the file-writing built-ins to the workspace: after
// the (unconfined) defaults are added, each enabled writer is replaced by an
// instance bound to writeRoots (preserving registry order).
// forbidReadRoots confines the read/list/search built-ins so they cannot peek at
// the listed directories.
// When workDir is non-empty, tools resolve relative paths against it instead of
// the process cwd, enabling concurrent multi-project sessions.
// sessionGuard blocks writer-tool targets inside Reasonix's own session stores
// and makes bash warn when a command references them. managedConfig names the
// Reasonix-owned config files writable outside writeRoots after a fresh
// per-write human approval.
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver, sessionGuard builtin.SessionDataGuard, managedConfig builtin.ManagedConfigPaths, overlay builtin.FileOverlay, terminal builtin.TerminalRunner) {
// If a workspace directory is set, use workspace-bound tools that resolve
// paths relative to that directory. Otherwise fall back to the process-cwd
// compile-time builtins.
if workDir != "" {
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard, ManagedConfig: managedConfig, FileOverlay: overlay, Terminal: terminal}
for _, t := range ws.Tools(enabled...) {
reg.Add(t)
}
return
}
if len(enabled) == 0 {
for _, t := range tool.Builtins() {
reg.Add(t)
}
} else {
for _, name := range enabled {
if t, ok := tool.LookupBuiltin(name); ok {
reg.Add(t)
} else {
fmt.Fprintf(stderr, "warning: unknown built-in tool %q\n", name)
}
}
}
// Replace the unconfined defaults with confined instances (registry order is
// preserved on replace): file-writers bound to the workspace, read tools
// bound to forbid-read roots, bash to the OS sandbox, web_fetch to the proxy.
// Only replace tools actually enabled/present.
confined := append(builtin.ConfineWriters(writeRoots, sessionGuard, managedConfig),
builtin.ConfineBash(bashSpec, sessionGuard, bashTimeout),
builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots),
builtin.ConfineWebFetch(proxySpec))
confined = append(confined, builtin.ConfineReaders(forbidReadRoots)...)
for _, t := range confined {
if _, ok := reg.Get(t.Name()); ok {
reg.Add(t)
}
}
}
func builtinToolEnabled(enabled []string, name string) bool {
if len(enabled) == 0 {
return true
}
name = strings.TrimSpace(name)
for _, candidate := range enabled {
if strings.TrimSpace(candidate) == name {
return true
}
}
return false
}
// partitionByTier splits configured plugin entries into eager (block boot until
// ready) and background (placeholder + start spawn now). Entries with an empty,
// legacy lazy, or unrecognised tier land in background.
func partitionByTier(entries []config.PluginEntry) (eager, bg []config.PluginEntry) {
for _, e := range entries {
switch e.ResolvedTier() {
case "eager":
eager = append(eager, e)
default:
bg = append(bg, e)
}
}
return eager, bg
}
// PluginSpecs maps configured plugin entries to plugin.Spec, expanding ${VAR}
// references. Exported so custom assemblers can connect the config's plugins
// alongside their own (e.g. ACP's per-session MCP servers).
func PluginSpecs(entries []config.PluginEntry) []plugin.Spec {
return PluginSpecsForRoot(entries, "")
}
// PluginSpecsForRoot maps configured plugin entries to plugin.Spec and applies
// workspace-aware compatibility overrides for known cwd-sensitive servers.
func PluginSpecsForRoot(entries []config.PluginEntry, workspaceRoot string) []plugin.Spec {
return PluginSpecsForRootWithPlanModeAllowedTools(entries, workspaceRoot, nil)
}
// PluginSpecOptions carries runtime policy that is not stored on each plugin
// entry but still needs to reach plugin.Spec.
type PluginSpecOptions struct {
DefaultCallTimeout time.Duration
PlanModeAllowedTools []string
}
// PluginSpecsForRootWithPlanModeAllowedTools also promotes model-visible MCP
// names declared in agent.plan_mode_allowed_tools to trusted read-only model
// names for their matching server. This keeps the planner/read-only research
// trust path aligned with the plan-mode execution escape valve.
func PluginSpecsForRootWithPlanModeAllowedTools(entries []config.PluginEntry, workspaceRoot string, allowedTools []string) []plugin.Spec {
return PluginSpecsForRootWithOptions(entries, workspaceRoot, PluginSpecOptions{
PlanModeAllowedTools: allowedTools,
})
}
// PluginSpecsForRootWithOptions maps configured plugin entries to plugin.Spec
// and injects runtime policy such as the global MCP call timeout.
func PluginSpecsForRootWithOptions(entries []config.PluginEntry, workspaceRoot string, opts PluginSpecOptions) []plugin.Spec {
specs := make([]plugin.Spec, len(entries))
for i, e := range entries {
specs[i] = pluginSpecFromEntryWithOptions(e, workspaceRoot, opts)
}
return applyPlanModeAllowedMCPToolTrust(specs, opts.PlanModeAllowedTools)
}
func pluginSpecFromEntryWithOptions(e config.PluginEntry, workspaceRoot string, opts PluginSpecOptions) plugin.Spec {
e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment
return plugin.ApplyKnownOverrides(plugin.Spec{
Name: e.Name,
Type: e.Type,
Command: e.Command,
Args: e.Args,
Env: e.Env,
URL: e.URL,
Headers: e.Headers,
DefaultCallTimeout: opts.DefaultCallTimeout,
CallTimeout: secondsDuration(e.CallTimeoutSeconds),
ToolTimeouts: toolTimeoutDurations(e.ToolTimeoutSeconds),
ReadOnlyToolNames: trustedRawReadOnlyToolNames(e.TrustedReadOnlyTools),
}, workspaceRoot)
}
func secondsDuration(seconds int) time.Duration {
if seconds <= 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
func toolTimeoutDurations(seconds map[string]int) map[string]time.Duration {
if len(seconds) == 0 {
return nil
}
out := make(map[string]time.Duration, len(seconds))
for name, sec := range seconds {
name = strings.TrimSpace(name)
if name == "" || sec <= 0 {
continue
}
out[name] = time.Duration(sec) * time.Second
}
if len(out) == 0 {
return nil
}
return out
}
func applyKnownPluginOverrides(specs []plugin.Spec, workspaceRoot string) []plugin.Spec {
out := make([]plugin.Spec, len(specs))
for i, spec := range specs {
out[i] = plugin.ApplyKnownOverrides(spec, workspaceRoot)
}
return out
}
func applyDefaultMCPCallTimeout(specs []plugin.Spec, timeout time.Duration) []plugin.Spec {
if len(specs) == 0 || timeout <= 0 {
return specs
}
out := make([]plugin.Spec, len(specs))
for i, spec := range specs {
out[i] = spec
if out[i].DefaultCallTimeout <= 0 {
out[i].DefaultCallTimeout = timeout
}
}
return out
}
func applyPlanModeAllowedMCPToolTrust(specs []plugin.Spec, allowedTools []string) []plugin.Spec {
if len(specs) == 0 || len(allowedTools) == 0 {
return specs
}
out := make([]plugin.Spec, len(specs))
for i, spec := range specs {
out[i] = spec
prefix := plugin.ToolPrefix(spec.Name)
clonedModelNames := false
for _, name := range allowedTools {
name = strings.TrimSpace(name)
if !strings.HasPrefix(name, prefix) || len(name) <= len(prefix) {
continue
}
if out[i].ReadOnlyModelToolNames == nil {
out[i].ReadOnlyModelToolNames = map[string]bool{}
clonedModelNames = true
} else if !clonedModelNames {
out[i].ReadOnlyModelToolNames = cloneBoolMap(spec.ReadOnlyModelToolNames)
clonedModelNames = true
}
out[i].ReadOnlyModelToolNames[name] = true
}
}
return out
}
func trustedRawReadOnlyToolNames(names []string) map[string]bool {
if len(names) == 0 {
return nil
}
out := map[string]bool{}
for _, name := range names {
name = strings.TrimSpace(name)
if name != "" {
out[name] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func planModeTrustedMCPServers(specs map[string]plugin.Spec) map[string]bool {
if len(specs) == 0 {
return nil
}
out := map[string]bool{}
for name, spec := range specs {
if len(spec.ReadOnlyToolNames) > 0 || len(spec.ReadOnlyModelToolNames) > 0 {
out[name] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func cloneBoolMap(in map[string]bool) map[string]bool {
if len(in) == 0 {
return nil
}
out := make(map[string]bool, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// autoShellPrefer reports whether [tools.shell] left the interpreter to
// auto-detection, so the "fell back to PowerShell" hint is suppressed once the
// user has explicitly chosen a shell.
func autoShellPrefer(prefer string) bool {
p := strings.ToLower(strings.TrimSpace(prefer))
return p == "" || p == "auto"
}
// MCPStartupNotice formats the warning shown when configured MCP servers failed
// to connect, naming the first few; ok is false when none failed.
func MCPStartupNotice(failures []plugin.Failure) (text, detail string, ok bool) {
if len(failures) == 0 {
return "", "", false
}
names := make([]string, 0, min(len(failures), 3))
details := make([]string, 0, len(failures))
for i, f := range failures {
if i >= 3 {
continue
}
names = append(names, f.Name)
}
for _, f := range failures {
line := f.Name
if strings.TrimSpace(f.Error) != "" {
line += ": " + strings.TrimSpace(f.Error)
}
details = append(details, line)
}
more := ""
if len(failures) > len(names) {
more = fmt.Sprintf(" (+%d more)", len(failures)-len(names))
}
return "Some MCP servers failed to start; run /mcp for details.", fmt.Sprintf("%d MCP server(s) failed to start: %s%s\n%s",
len(failures), strings.Join(names, ", "), more, strings.Join(details, "\n")), true
}
// LSPSpecs returns the language → server map: the built-in defaults overlaid with
// any user overrides. A user entry may set only the fields it wants to change;
// empty fields keep the default for that language.
func LSPSpecs(cfg config.LSPConfig) map[string]lsp.ServerSpec {
specs := lsp.DefaultSpecs()
for lang, s := range cfg.Servers {
spec := specs[lang]
if s.Command != "" {
spec.Command = s.Command
}
if s.Args != nil {
spec.Args = s.Args
}
if s.Env != nil {
spec.Env = s.Env
}
if s.LanguageID != "" {
spec.LanguageID = s.LanguageID
}
if s.Extensions != nil {
spec.Extensions = s.Extensions
}
if s.InstallHint != "" {
spec.InstallHint = s.InstallHint
}
if spec.LanguageID == "" {
spec.LanguageID = lang
}
specs[lang] = spec
}
return specs
}
func providerNames(cfg *config.Config) string {
names := make([]string, len(cfg.Providers))
for i, p := range cfg.Providers {
names[i] = p.Name
}
return strings.Join(names, "/")
}