2005 lines
76 KiB
Go
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, "/")
|
|
}
|