| 1 | // Package boot assembles a ready-to-drive control.Controller from configuration: |
| 2 | // it loads config, resolves the model(s), builds the tool registry (built-ins + |
| 3 | // plugins), wires the permission gate, and constructs the executor — optionally |
| 4 | // wrapping it in a two-model Coordinator. It is the one place that turns "what the |
| 5 | // user configured" into "a Controller a frontend can drive", so every frontend — |
| 6 | // the terminal TUI, the HTTP/SSE server, the desktop webview — shares the exact |
| 7 | // same assembly instead of each re-deriving it. Frontends pass only a sink and a |
| 8 | // couple of run knobs; everything else comes from config. |
| 9 | package boot |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "errors" |
| 14 | "fmt" |
| 15 | "io" |
| 16 | "log/slog" |
| 17 | "os" |
| 18 | "path/filepath" |
| 19 | "runtime" |
| 20 | "slices" |
| 21 | "strconv" |
| 22 | "strings" |
| 23 | "sync/atomic" |
| 24 | "time" |
| 25 | |
| 26 | "reasonix/internal/ablation" |
| 27 | "reasonix/internal/agent" |
| 28 | "reasonix/internal/agentpreset" |
| 29 | "reasonix/internal/billing" |
| 30 | "reasonix/internal/browser" |
| 31 | "reasonix/internal/capability" |
| 32 | "reasonix/internal/command" |
| 33 | "reasonix/internal/config" |
| 34 | "reasonix/internal/control" |
| 35 | "reasonix/internal/environment" |
| 36 | "reasonix/internal/event" |
| 37 | "reasonix/internal/extension" |
| 38 | "reasonix/internal/extension/dispatch" |
| 39 | "reasonix/internal/extension/protocol" |
| 40 | "reasonix/internal/extension/providerext" |
| 41 | "reasonix/internal/extension/sidecar" |
| 42 | "reasonix/internal/extension/uihub" |
| 43 | "reasonix/internal/guardian" |
| 44 | "reasonix/internal/history" |
| 45 | "reasonix/internal/hook" |
| 46 | "reasonix/internal/imageinput" |
| 47 | "reasonix/internal/installsource" |
| 48 | "reasonix/internal/jobs" |
| 49 | "reasonix/internal/lsp" |
| 50 | "reasonix/internal/mcplaunch" |
| 51 | "reasonix/internal/memory" |
| 52 | "reasonix/internal/migration" |
| 53 | "reasonix/internal/netclient" |
| 54 | "reasonix/internal/outputstyle" |
| 55 | "reasonix/internal/permission" |
| 56 | "reasonix/internal/persistentshell" |
| 57 | "reasonix/internal/plugin" |
| 58 | "reasonix/internal/productdocs" |
| 59 | "reasonix/internal/provider" |
| 60 | "reasonix/internal/sandbox" |
| 61 | "reasonix/internal/secrets" |
| 62 | "reasonix/internal/session" |
| 63 | "reasonix/internal/sessioncontext" |
| 64 | "reasonix/internal/sessiontemp" |
| 65 | "reasonix/internal/skill" |
| 66 | "reasonix/internal/stats" |
| 67 | "reasonix/internal/taskmonitor" |
| 68 | "reasonix/internal/tool" |
| 69 | "reasonix/internal/tool/builtin" |
| 70 | "reasonix/internal/tool/sessiontool" |
| 71 | "reasonix/internal/workspacelease" |
| 72 | ) |
| 73 | |
| 74 | // ErrUnknownModel is returned by Build when the configured model can't be |
| 75 | // resolved to a provider — e.g. a default_model left over from a renamed or |
| 76 | // removed provider. Callers can detect it (errors.Is) to re-run setup. |
| 77 | var ErrUnknownModel = errors.New("unknown model") |
| 78 | |
| 79 | func agentKeepPolicy(keep []string) agent.KeepPolicy { |
| 80 | if keep == nil { |
| 81 | return agent.KeepErrors | agent.KeepUserMarked |
| 82 | } |
| 83 | var p agent.KeepPolicy |
| 84 | for _, k := range keep { |
| 85 | switch strings.TrimSpace(k) { |
| 86 | case "errors": |
| 87 | p |= agent.KeepErrors |
| 88 | case "user_marked": |
| 89 | p |= agent.KeepUserMarked |
| 90 | } |
| 91 | } |
| 92 | return p |
| 93 | } |
| 94 | |
| 95 | // Options carries the per-run knobs a frontend chooses; everything else is |
| 96 | // read from configuration. Model "" falls back to default_model; MaxSteps 0 |
| 97 | // uses automatic execution; RequireKey fails fast on a missing key. |
| 98 | type Options struct { |
| 99 | // ModelSettings supplies an immutable desktop credential-proxy resolver. |
| 100 | // The bundle contains virtual tunnel credentials only and stays in memory. |
| 101 | ModelSettings *config.ModelRuntimeSettings |
| 102 | Model string |
| 103 | MaxSteps int |
| 104 | MaxStepsKey string |
| 105 | RequireKey bool |
| 106 | Sink event.Sink |
| 107 | // EffortOverride is a session-local reasoning effort override. Nil means use |
| 108 | // the resolved provider config; a non-nil empty string means provider default. |
| 109 | EffortOverride *string |
| 110 | // EffortModel binds an inherited override to its original model. Empty |
| 111 | // means this build received an explicit selection for Options.Model. |
| 112 | EffortModel string |
| 113 | // ConfigSnapshot is an optional, caller-owned immutable configuration for |
| 114 | // this assembly. Desktop passes the snapshot used to resolve the selection |
| 115 | // so a concurrent settings edit cannot change another role halfway through. |
| 116 | ConfigSnapshot *config.Config |
| 117 | // PermissionAllow adds process-local allow rules (for example CLI |
| 118 | // --allowed-tools). They override configured ask rules but never deny rules |
| 119 | // and are not persisted. |
| 120 | PermissionAllow []string |
| 121 | // AdditionalDirs grants this session's file writers and sandboxed shell |
| 122 | // access to extra directories without changing persisted sandbox config. |
| 123 | AdditionalDirs []string |
| 124 | // Stderr is the writer for diagnostic warnings and plugin subprocess |
| 125 | // stderr output. When nil, defaults to os.Stderr. Interactive terminal |
| 126 | // frontends must provide a private diagnostic writer (or io.Discard) so |
| 127 | // background output cannot corrupt the TUI's terminal raw mode. |
| 128 | Stderr io.Writer |
| 129 | // WorkspaceRoot is the project root directory for config, skills, memory, |
| 130 | // commands, hooks, and tool confinement. When empty, the current working |
| 131 | // directory is used (CLI default). Desktop tabs pass their project root here |
| 132 | // so each tab loads its own config/skills/hooks without changing the process |
| 133 | // cwd — enabling concurrent multi-project sessions. |
| 134 | WorkspaceRoot string |
| 135 | // StatsSource labels this frontend's usage records (desktop/cli/serve). |
| 136 | // Empty disables usage recording for this controller. |
| 137 | StatsSource string |
| 138 | // FileBranchesOnly keeps fork/branch/switch/rewind on separate session |
| 139 | // files instead of heads inside a schema-2 log. |
| 140 | FileBranchesOnly bool |
| 141 | TaskStore taskmonitor.WriteStore // Authoritative store, never a SQLite catalog. |
| 142 | // OnConfigLoadWarnings accepts resilient-loader warnings. Returning true |
| 143 | // lets boot suppress the duplicate migration diagnostic. |
| 144 | OnConfigLoadWarnings func([]string) bool |
| 145 | // ExtraPlugins are session-scoped MCP servers supplied by a host transport |
| 146 | // (for example ACP session/new). They are connected eagerly for this |
| 147 | // controller but are not persisted to reasonix.toml. |
| 148 | ExtraPlugins []plugin.Spec |
| 149 | // AgentPreset and TokenMode are retired compatibility inputs. Recognized |
| 150 | // values use standard execution; unknown values keep the standard default. |
| 151 | AgentPreset string |
| 152 | TokenMode string |
| 153 | // SessionDir overrides where persisted chat transcripts are written. When |
| 154 | // empty, the shared CLI/global session directory is used. |
| 155 | SessionDir string |
| 156 | // SessionService is shared by all controllers on one host. Rebuild injects |
| 157 | // the previous service/runtime so model changes keep the immutable session |
| 158 | // identity and writer owned by the same SessionRuntime. |
| 159 | SessionService *session.Service |
| 160 | SessionRuntime *session.Runtime |
| 161 | SessionHostID string |
| 162 | SessionCreateOptions session.CreateOptions |
| 163 | // SharedHost is an optional plugin.Host shared across controllers for the |
| 164 | // same workspace root. When set, boot.Build reuses its running clients |
| 165 | // instead of creating new subprocesses, and the caller manages the host's |
| 166 | // lifecycle. When nil, Build creates and owns a new host as before. |
| 167 | SharedHost *plugin.Host |
| 168 | // MCPHostProfile is the capability surface for hosts Build creates; |
| 169 | // ignored when SharedHost is set (it fixed its own profile). |
| 170 | MCPHostProfile plugin.HostProfile |
| 171 | // CleanupPendingReconciler retries delayed physical cleanup for session |
| 172 | // artifacts left by a previous process. Nil uses the core physical-delete |
| 173 | // reconciler; frontends with different deletion semantics can override it. |
| 174 | CleanupPendingReconciler func(sessionDir string) error |
| 175 | // ApprovalTimeout bounds how long a tool-approval or ask prompt blocks for a |
| 176 | // user decision. Zero (default) waits forever — correct for an interactive |
| 177 | // terminal. Headless/bot frontends pass a positive value so an unanswered |
| 178 | // prompt can't wedge the session indefinitely (#4626, #4402). |
| 179 | ApprovalTimeout time.Duration |
| 180 | // HeadlessApprovalMode selects the non-interactive tool-approval contract |
| 181 | // (control.ToolApprovalAuto/DontAsk/Yolo) applied to every headless-only gate |
| 182 | // this boot constructs: the top-level executor, task/read_only_task, |
| 183 | // writer-capable skill sub-agents, and the planner runner. Empty (or "ask") |
| 184 | // keeps the default fail-closed headless gate. Callers that later call |
| 185 | // Controller.ApplyHeadlessApprovalMode with a |
| 186 | // different mode than they passed here should also pass it here, or |
| 187 | // sub-agent gates will not match the parent executor's mode. |
| 188 | HeadlessApprovalMode string |
| 189 | // Session recovery and transition hooks let frontends keep local ownership metadata aligned. |
| 190 | SessionRecoveryMeta func(control.SessionRecoveryRequest) agent.BranchMeta |
| 191 | OnSessionRecovered func(control.SessionRecoveryInfo) error |
| 192 | OnSessionTransition func(control.SessionTransitionInfo) error |
| 193 | OnSessionRotation func(context.Context, control.SessionRotationRequest) (control.SessionRotationPlan, error) |
| 194 | BeforeInboxDispatch func(*control.Controller) (func(), error) |
| 195 | // OnSessionTitleChanged lets a host project the canonical BranchMeta title |
| 196 | // into compatibility indexes and refresh notifications after the current |
| 197 | // conversation renames itself through set_session_title. |
| 198 | OnSessionTitleChanged sessiontool.TitleChangedFunc |
| 199 | // SubagentParentLive reports whether this process currently owns or is |
| 200 | // building the parent session. Desktop uses it to avoid probing a live tab's |
| 201 | // lease during stale-subagent cleanup. Nil preserves lease-only cleanup. |
| 202 | SubagentParentLive func(sessionPath string) bool |
| 203 | // FileOverlay and TerminalRunner let a host transport (ACP) serve file |
| 204 | // content from editor buffers and run foreground bash in a host terminal. |
| 205 | // Both only change where tool I/O happens — tool names, descriptions, and |
| 206 | // schemas stay byte-identical, so the provider-visible surface is unchanged. |
| 207 | FileOverlay builtin.FileOverlay |
| 208 | TerminalRunner builtin.TerminalRunner |
| 209 | // BrowserExecutor attaches the host's browser; nil registers nothing. Its |
| 210 | // tools are registry-only: use_capability reaches them while the provider- |
| 211 | // visible surface never changes, so the cached prompt prefix stays identical. |
| 212 | BrowserExecutor browser.Executor |
| 213 | // ProviderResolver routes every model role through a caller-owned provider |
| 214 | // catalog. Nil preserves local behavior. |
| 215 | ProviderResolver provider.Resolver |
| 216 | // Ablation switches subsystems off for a benchmark arm, and is also the |
| 217 | // process-local hard override supervised ACP workers use to force the planner |
| 218 | // off. It wins over user/project configuration without mutating config or |
| 219 | // changing the provider-visible prompt/tool surface. The zero value runs |
| 220 | // everything. |
| 221 | Ablation ablation.Set |
| 222 | // SandboxNetworkOverride and WorkspaceOnly are process-local hard bounds for |
| 223 | // supervised ACP workers. Nil/false preserve normal Reasonix config. |
| 224 | SandboxNetworkOverride *bool |
| 225 | SandboxBashOverride string |
| 226 | WorkspaceOnly bool |
| 227 | PinnedContextLoader control.PinnedContextLoader |
| 228 | SessionTemp *sessiontemp.Manager // session-private temp manager; Rebuild reuses old's |
| 229 | PersistentShell *persistentshell.Manager |
| 230 | RuntimeReload |
| 231 | // deferPublish keeps a replacement generation private until migration and |
| 232 | // commit succeed. Cold BuildRuntime leaves this false and publishes at boot. |
| 233 | deferPublish bool |
| 234 | } |
| 235 | |
| 236 | func recoveryHeadlessMode(opts Options) bool { |
| 237 | return strings.TrimSpace(opts.HeadlessApprovalMode) != "" |
| 238 | } |
| 239 | |
| 240 | // build is the assembly body behind BuildRuntime (and the Build compat |
| 241 | // wrapper): it loads config, resolves the model(s), wires the full runtime, |
| 242 | // and freezes the extension kernel snapshot from the objects it just |
| 243 | // assembled. The returned controller owns plugin subprocesses; call Close |
| 244 | // (via Controller.Close) to release them. |
| 245 | func build(ctx context.Context, opts Options) (*BuildResult, error) { |
| 246 | ctx, opts, owner, fileWriteReceipt := bindRuntimeOwner(ctx, opts) |
| 247 | stderr := opts.Stderr |
| 248 | if stderr == nil { |
| 249 | stderr = os.Stderr |
| 250 | } |
| 251 | root := resolveWorkspaceRoot(opts.WorkspaceRoot) |
| 252 | additionalDirs, err := normalizeAdditionalDirs(root, opts.AdditionalDirs) |
| 253 | if err != nil { |
| 254 | return nil, err |
| 255 | } |
| 256 | // Import v1/v0.5 config before Load so this boot sees the new config + ~/.env. |
| 257 | // CLI Run also calls this before config-only commands; keep a shared fallback. |
| 258 | migrated, migErr := config.MigrateLegacyIfNeededForRoot(root) |
| 259 | deepSeekProtocolMigrated, deepSeekProtocolMigErr := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()) |
| 260 | stepLimitsMigrated, stepLimitMigErr := config.MigrateLegacyAgentStepLimitsForRoot(root) |
| 261 | redactToolOutputMigrated, redactToolOutputMigErr := config.MigrateLegacyRedactToolOutputForRoot(root) |
| 262 | memoryCompilerMigrated, memoryCompilerMigErr := config.MigrateLegacyMemoryCompilerForRoot(root) |
| 263 | multiThresholdMigrated, multiThresholdMigErr := config.MigrateLegacyMultiThresholdCompactionForRoot(root) |
| 264 | config.MigrateLegacyMCPTiersForRoot(root) |
| 265 | cfg, opts, err := resolveBuildSelection(root, opts) |
| 266 | if err != nil { |
| 267 | return nil, err |
| 268 | } |
| 269 | deepSeekProtocolMigErr = deepSeekProtocolMigrationNoticeError(handleConfigLoadWarnings(opts, cfg), deepSeekProtocolMigErr) |
| 270 | if err := preflightRoleReasoning(cfg, opts, opts.ProviderResolver, false); err != nil { |
| 271 | return nil, err |
| 272 | } |
| 273 | // Arm the credential-protection layers from the user-global [secrets] |
| 274 | // section before any tool, hook, or plugin subprocess can spawn. Package |
| 275 | // globals are correct here because [secrets] is user-global (project |
| 276 | // reasonix.toml cannot override it), so concurrent workspaces agree. |
| 277 | secrets.SetFilterSubprocessEnv(cfg.Secrets.FilterSubprocessEnv) |
| 278 | secrets.SetProtectSensitiveFiles(cfg.Secrets.ProtectSensitiveFiles) |
| 279 | secrets.RegisterCredentialEnvKeys(cfg.CredentialEnvNames()) |
| 280 | |
| 281 | // Serialize the frontend's sink once: background jobs (below) emit from their |
| 282 | // own goroutines, which can overlap a running turn's emission, so every emitter |
| 283 | // shares this synchronized sink. It is created before extension preflight so |
| 284 | // sidecar warnings and host/ui/* publishes land on the same channel as every |
| 285 | // later notice. The job manager is session-scoped — its jobs outlive a turn |
| 286 | // and are cancelled by Controller.Close. |
| 287 | // |
| 288 | // CostQuote must run before every host consumer (stats recorder, CLI |
| 289 | // metrics via opts.Sink, ACP/eventwire bridges, Desktop) so all see the |
| 290 | // same occurrence-time quote. Order from the agent: |
| 291 | // GoalUsageTee → Sync → CostQuote → [Recorder] → frontend |
| 292 | // The controller coalesces before its ledger so every consumer shares boundaries. |
| 293 | quoteCtx := &event.QuoteContext{ |
| 294 | DisplayRequest: billing.DisplayRequest{ |
| 295 | Currency: cfg.ExplicitDisplayCurrency(), |
| 296 | Source: billing.DisplaySourceExplicit, |
| 297 | }, |
| 298 | BillingModeForModel: func(modelRef string) string { |
| 299 | entry, ok := cfg.ResolveModel(modelRef) |
| 300 | if !ok { |
| 301 | return "" |
| 302 | } |
| 303 | return entry.ProviderBillingMode() |
| 304 | }, |
| 305 | PricingContextForModel: func(modelRef string) billing.PricingContext { |
| 306 | entry, ok := cfg.ResolveModel(modelRef) |
| 307 | if !ok { |
| 308 | return billing.PricingContext{} |
| 309 | } |
| 310 | return entry.PricingContextForModel(entry.Model) |
| 311 | }, |
| 312 | } |
| 313 | // Innermost: frontend sink (CLI metrics/ACP/Desktop bridge live here). |
| 314 | quoted := opts.Sink |
| 315 | // Record billable usage after quoting so history JSONL can store CostQuote. |
| 316 | if source := strings.TrimSpace(opts.StatsSource); source != "" { |
| 317 | quoted = stats.NewRecorder(quoted, config.StatsDir(), source) |
| 318 | } |
| 319 | quoted = event.NewCostQuoteSink(quoted, quoteCtx) |
| 320 | sink := event.Sync(quoted) |
| 321 | |
| 322 | // Both sink wraps must complete BEFORE the extension UI hub closes over the |
| 323 | // sink variable: a sidecar publish during preflight lands on this closure |
| 324 | // from a wire-handler goroutine, and any later reassignment races it. |
| 325 | // Goal token-budget accounting: the controller detects this tee and |
| 326 | // attributes billable usage to the active goal turn's recorder. Both the |
| 327 | // tee must ride the shared sink agents emit into directly. |
| 328 | sink = control.NewGoalUsageTee(sink) |
| 329 | |
| 330 | // Extension preflight (stages 5b/7): start the installed, enabled v2 runtime |
| 331 | // packages ONCE, here, before model resolution, so plugin-namespaced refs |
| 332 | // (plugin/<plugin>/<provider>/<model>) resolve on the very first boot and the |
| 333 | // same sidecar generation feeds the executor, planner, guardian, sub-agents, |
| 334 | // the snapshot assembly, and the frontend catalog. With no runtime package |
| 335 | // installed preflight is a no-op and the whole build below takes the |
| 336 | // untouched pre-sidecar path. The generation moves up with it: the sidecar |
| 337 | // handshake's session context carries this build's generation, and a fresh |
| 338 | // controller has no session path yet, so the session ID is generation-scoped |
| 339 | // (the handshake only requires a stable, non-empty identity). |
| 340 | generation := nextRuntimeGeneration() |
| 341 | sessionID := fmt.Sprintf("boot-%d", generation) |
| 342 | proxySpec := cfg.NetworkProxySpec() |
| 343 | extWarn := func(msg string) { |
| 344 | redacted := secrets.RedactCredentials(msg) |
| 345 | slog.Warn("boot: extension runtime: "+redacted, "root", root) |
| 346 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: redacted}) |
| 347 | } |
| 348 | // Stage 8a: the host extension UI hub serves every sidecar's host/ui/* calls |
| 349 | // for this generation — publications become frontend events through the |
| 350 | // controller sink, blocking prompts ride the controller's Ask channel. The |
| 351 | // controller only exists after control.New below, so both seams indirect |
| 352 | // through ctrlRef; traffic before that (a sidecar publishing during its |
| 353 | // handshake) falls back to the same sink directly, matching the emission the |
| 354 | // controller would have made. |
| 355 | var ctrlRef atomic.Pointer[control.Controller] |
| 356 | // Readiness signals for gateExtensionUIRequest: a sidecar may legally |
| 357 | // issue host/ui/request right after extension/initialized, before the |
| 358 | // controller exists. ready closes at ctrlRef.Store; failed closes on any |
| 359 | // build error before the RuntimeSet takes ownership (the pendingMgr defer |
| 360 | // below), so a startup request never hangs a dying build. |
| 361 | controllerReady := make(chan struct{}) |
| 362 | controllerBuildFailed := make(chan struct{}) |
| 363 | extUIHub := uihub.New(uihub.Options{ |
| 364 | SessionID: sessionID, |
| 365 | Generation: generation, |
| 366 | Owner: owner, |
| 367 | Emit: func(ev event.Event) { |
| 368 | if c := ctrlRef.Load(); c != nil { |
| 369 | c.EmitExtensionEvent(ev) |
| 370 | return |
| 371 | } |
| 372 | sink.Emit(ev) |
| 373 | }, |
| 374 | Request: func(reqCtx context.Context, req uihub.HubRequest) (map[string]any, bool, error) { |
| 375 | return gateExtensionUIRequest(reqCtx, ctrlRef.Load, controllerReady, controllerBuildFailed, |
| 376 | func(c *control.Controller) (map[string]any, bool, error) { |
| 377 | return uihub.AskRequestFunc(c.Ask)(reqCtx, req) |
| 378 | }) |
| 379 | }, |
| 380 | Warn: func(msg string) { |
| 381 | slog.Warn("boot: extension UI hub: "+msg, "root", root) |
| 382 | }, |
| 383 | }) |
| 384 | extensionMgr, err := preflightExtensionRuntimes(ctx, config.ReasonixHomeDir(), extensionBoot{ |
| 385 | session: protocol.SessionContext{SessionID: sessionID, WorkspaceRoot: root, Generation: generation}, |
| 386 | ui: extUIHub, |
| 387 | onWarning: extWarn, |
| 388 | }, opts.Extensions, planForPreflight(opts, generation)) |
| 389 | if err != nil { |
| 390 | return nil, fmt.Errorf("boot: %w", err) |
| 391 | } |
| 392 | // Until the RuntimeSet takes ownership at snapshot assembly, every error |
| 393 | // path between here and there must retire the preflighted sidecars — no |
| 394 | // process may outlive a failed build. |
| 395 | pendingMgr := extensionMgr |
| 396 | defer func() { |
| 397 | if pendingMgr != nil { |
| 398 | close(controllerBuildFailed) |
| 399 | _ = pendingMgr.Close() |
| 400 | } |
| 401 | }() |
| 402 | |
| 403 | // The build's provider resolution base: the caller-owned broker when |
| 404 | // injected, the local config-backed resolver otherwise. When a started |
| 405 | // sidecar declares providers, fold them in NOW (stage 7) with the |
| 406 | // provider:<ref> slot claims from the same manifest data the kernel's |
| 407 | // ReplaceClaims pass uses, so first-boot model resolution sees them. A |
| 408 | // conflict with the base catalog that lacks the plugin's claim is fatal, |
| 409 | // the same class as a required runtime that cannot start: booting without |
| 410 | // the declared provider would silently change what the session is. |
| 411 | modelCapabilities := config.NewModelCapabilityResolver() |
| 412 | baseResolver := opts.ProviderResolver |
| 413 | if baseResolver == nil { |
| 414 | baseResolver = NewLocalProviderResolverWithCapabilities(cfg, proxySpec, modelCapabilities) |
| 415 | } |
| 416 | effectiveResolver := opts.ProviderResolver |
| 417 | if effectiveResolver == nil { |
| 418 | effectiveResolver = baseResolver |
| 419 | } |
| 420 | var extensionResolver provider.Resolver |
| 421 | if extensionMgr != nil { |
| 422 | declares := false |
| 423 | for _, client := range extensionMgr.Clients() { |
| 424 | if len(client.Handshake().Providers) > 0 { |
| 425 | declares = true |
| 426 | break |
| 427 | } |
| 428 | } |
| 429 | if declares { |
| 430 | claims, claimsErr := resolveReplacementClaims(extensionMgr.Contributions()) |
| 431 | if claimsErr != nil { |
| 432 | return nil, fmt.Errorf("boot: %w", claimsErr) |
| 433 | } |
| 434 | merged, mergeErr := mergeSidecarProviders(baseResolver, extensionMgr, claims, owner) |
| 435 | if mergeErr != nil { |
| 436 | return nil, fmt.Errorf("boot: %w", mergeErr) |
| 437 | } |
| 438 | installSidecarStreamRouters(extensionMgr, merged) |
| 439 | effectiveResolver = merged |
| 440 | extensionResolver = merged |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | // Fall through a keyless default_model to the next configured chat model |
| 445 | // instead of hard-failing every command on "missing env X_API_KEY" (issue |
| 446 | // #6996). The fallback only kicks in when the caller did not pass an |
| 447 | // explicit opts.Model; explicit choices still fail loudly. |
| 448 | if err := preflightRoleReasoning(cfg, opts, effectiveResolver, true); err != nil { |
| 449 | return nil, err |
| 450 | } |
| 451 | modelName := opts.Model |
| 452 | if modelName == "" { |
| 453 | if resolved, _, ok := cfg.ResolveNewSessionChatModel(); ok { |
| 454 | modelName = resolved |
| 455 | } |
| 456 | } |
| 457 | // opts.AgentPreset/opts.TokenMode now seed the session quality floor (see |
| 458 | // the SetQualityFloor call after control.New); light folds to standard. |
| 459 | keepPolicy := agentKeepPolicy(cfg.Agent.Keep) |
| 460 | // Entry resolution: the caller-owned broker is authoritative for every |
| 461 | // ref; the extension-merged resolver only owns plugin refs — a config ref |
| 462 | // keeps the full config entry (kind, endpoint, credentials, balance URL, |
| 463 | // missing-key notice), exactly as without extensions installed. |
| 464 | entryResolver := opts.ProviderResolver |
| 465 | if entryResolver == nil && extensionResolver != nil && providerext.PluginRefOwner(modelName) != "" { |
| 466 | entryResolver = extensionResolver |
| 467 | } |
| 468 | entry, modelRef, err := resolveModelEntry(entryResolver, cfg, modelName) |
| 469 | if err != nil { |
| 470 | return nil, err |
| 471 | } |
| 472 | if opts.EffortOverride != nil { |
| 473 | entry.Effort = *opts.EffortOverride |
| 474 | if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" { |
| 475 | entry.Thinking = "adaptive" |
| 476 | } |
| 477 | } |
| 478 | // RequireKey fails fast on a missing credential (run/serve); plugin- |
| 479 | // namespaced refs carry no config credential — the extension provider holds |
| 480 | // its own keys — so the merged resolver's resolution is their only gate. |
| 481 | authentication := authenticationStateForModelEntry(entry, modelRef) |
| 482 | if opts.RequireKey && opts.ProviderResolver == nil && providerext.PluginRefOwner(modelName) == "" { |
| 483 | if err := cfg.Validate(modelName); err != nil { |
| 484 | if entry.RequiresAPIKey() && entry.APIKey() == "" { |
| 485 | authentication.Message = err.Error() |
| 486 | return nil, &control.AuthenticationError{State: authentication} |
| 487 | } |
| 488 | return nil, err |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | if migErr != nil { |
| 493 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Config migration did not complete.", Detail: "config migration from ~/.reasonix failed: " + migErr.Error()}) |
| 494 | } else if migrated != nil { |
| 495 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: migrated.Notice()}) |
| 496 | } |
| 497 | emitUserConfigUpgradeNotice(sink, cfg, deepSeekProtocolMigrated, deepSeekProtocolMigErr, config.TakeProviderEndpointRepairReceipts(config.UserConfigPath())) |
| 498 | if stepLimitsMigrated || cfg.IgnoredLegacyAgentStepLimits() { |
| 499 | level := event.LevelInfo |
| 500 | text := "Deprecated agent step limits were removed." |
| 501 | detail := "[agent].max_steps and planner_max_steps are no longer used; Reasonix now manages interactive progress automatically. " + |
| 502 | "Use the CLI --max-steps flag for a one-off run or [bot].max_steps for unattended bot sessions." |
| 503 | if stepLimitMigErr != nil { |
| 504 | level = event.LevelWarn |
| 505 | text = "Deprecated agent step limits were ignored." |
| 506 | detail += " The old keys were ignored but could not be removed: " + stepLimitMigErr.Error() |
| 507 | } |
| 508 | sink.Emit(event.Event{ |
| 509 | Kind: event.Notice, |
| 510 | Level: level, |
| 511 | Text: text, |
| 512 | Detail: detail, |
| 513 | }) |
| 514 | } else if stepLimitMigErr != nil { |
| 515 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Deprecated agent step-limit migration did not complete.", Detail: stepLimitMigErr.Error()}) |
| 516 | } |
| 517 | if redactToolOutputMigrated || redactToolOutputMigErr != nil { |
| 518 | level := event.LevelInfo |
| 519 | text := "Deprecated redact_tool_output setting was removed." |
| 520 | detail := "[secrets].redact_tool_output no longer has any effect: ordinary model/tool content and local session/job artifacts now preserve their original text. Explicit diagnostics and reasonix doctor redact-sessions still redact credential values." |
| 521 | if redactToolOutputMigErr != nil { |
| 522 | level = event.LevelWarn |
| 523 | text = "Deprecated redact_tool_output setting was ignored." |
| 524 | detail += " The old key could not be removed: " + redactToolOutputMigErr.Error() |
| 525 | } |
| 526 | sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail}) |
| 527 | } |
| 528 | if memoryCompilerMigrated || memoryCompilerMigErr != nil { |
| 529 | level := event.LevelInfo |
| 530 | text := "Deprecated memory_compiler setting was removed." |
| 531 | detail := "The Memory v5 execution compiler has been removed from Reasonix: [agent].memory_compiler no longer has any effect, user turns are never replaced by compiled execution contracts, and no compiler state is written. Old transcripts containing compiled turns still display normally." |
| 532 | if memoryCompilerMigErr != nil { |
| 533 | level = event.LevelWarn |
| 534 | text = "Deprecated memory_compiler setting was ignored." |
| 535 | detail += " The old key could not be removed: " + memoryCompilerMigErr.Error() |
| 536 | } |
| 537 | sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail}) |
| 538 | } |
| 539 | if multiThresholdMigrated || multiThresholdMigErr != nil { |
| 540 | level := event.LevelInfo |
| 541 | text := "上下文维护已简化为单一自动压缩阈值。" |
| 542 | detail := "Context maintenance now uses a single automatic compact_ratio (default 0.80). soft_compact_ratio, tool_result_snip_ratio, compact_force_ratio, cold_resume_prune, and context_editing were removed from config." |
| 543 | if multiThresholdMigErr != nil { |
| 544 | level = event.LevelWarn |
| 545 | text = "Deprecated multi-threshold compaction keys were ignored." |
| 546 | detail += " The old keys could not be removed: " + multiThresholdMigErr.Error() |
| 547 | } |
| 548 | sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail}) |
| 549 | } |
| 550 | migration.MigrateLegacyMemorySources(sink) |
| 551 | migration.MigrateLegacySessionSources(sink) |
| 552 | if ignored := cfg.IgnoredProjectDefaultModel(); ignored != "" { |
| 553 | 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)}) |
| 554 | } |
| 555 | |
| 556 | // A resolvable model whose API key env is unset would otherwise build fine |
| 557 | // (RequireKey is false so the UI stays reachable) and then fail silently on the |
| 558 | // first request, showing as an empty/dead model. Surface the cause up front. |
| 559 | if !opts.RequireKey && !authentication.Ready() { |
| 560 | if authentication.Status == control.AuthenticationCredentialStoreUnavailable { |
| 561 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "The credential store is unavailable.", Detail: "Reasonix could not read its credential file; open credential diagnostics before retrying"}) |
| 562 | } else { |
| 563 | 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)}) |
| 564 | } |
| 565 | } |
| 566 | // Every role setting lazily acquires a workspace write lease on the first |
| 567 | // real writer. Read-only turns never take the lease. |
| 568 | var workspaceLease *workspacelease.Owner |
| 569 | jobOptions := []jobs.Option{ |
| 570 | jobs.WithStalledWarningAfter(time.Duration(cfg.BackgroundJobStalledWarningSeconds()) * time.Second), |
| 571 | jobs.WithSessionOwnershipProbe(agent.SessionLeaseHeldByCurrentRuntime), |
| 572 | } |
| 573 | workspaceLease, err = workspacelease.New(root, config.WorkspaceLeaseDir(), func() { |
| 574 | sink.Emit(event.Event{ |
| 575 | Kind: event.Notice, |
| 576 | Level: event.LevelInfo, |
| 577 | Code: event.NoticeCodeWorkspaceLease, |
| 578 | Text: "Another session is writing to this workspace; this session will continue automatically when it is safe.", |
| 579 | Detail: "workspace write lease is busy; read-only work remains concurrent", |
| 580 | }) |
| 581 | }) |
| 582 | if err != nil { |
| 583 | return nil, fmt.Errorf("initialize workspace write lease: %w", err) |
| 584 | } |
| 585 | jobOptions = append(jobOptions, jobs.WithJobStartObserver(workspaceLease.RetainUntil)) |
| 586 | jm := jobs.NewManager(sink, jobOptions...) |
| 587 | sessionDir := opts.SessionDir |
| 588 | if sessionDir == "" { |
| 589 | sessionDir = config.SessionDir() |
| 590 | } |
| 591 | // The host owns the final-format SessionService. Boot only attaches an |
| 592 | // Agent to the exact service/runtime it receives; constructing a service |
| 593 | // here would create competing registries over the same writer files during |
| 594 | // model switches or multi-tab startup. |
| 595 | sessionService := opts.SessionService |
| 596 | if opts.SessionRuntime != nil && sessionService == nil { |
| 597 | return nil, errors.New("v3 session runtime requires a session service") |
| 598 | } |
| 599 | reconcileCleanupPending := opts.CleanupPendingReconciler |
| 600 | if reconcileCleanupPending == nil { |
| 601 | reconcileCleanupPending = control.ReconcileCleanupPending |
| 602 | } |
| 603 | if err := reconcileCleanupPending(sessionDir); err != nil { |
| 604 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "cleanup-pending reconciliation failed: " + err.Error()}) |
| 605 | } |
| 606 | |
| 607 | // proxySpec was computed during extension preflight (the merged resolver's |
| 608 | // local base needs it); validate it before any provider construction. |
| 609 | if err := netclient.Validate(proxySpec); err != nil { |
| 610 | return nil, err |
| 611 | } |
| 612 | balanceClient, err := netclient.NewHTTPClient(proxySpec, netclient.TransportOptions{}) |
| 613 | if err != nil { |
| 614 | return nil, err |
| 615 | } |
| 616 | execProv, err := resolveProvider(effectiveResolver, cfg, proxySpec, provider.Selection{Ref: modelRef, Effort: opts.EffortOverride}) |
| 617 | if err != nil { |
| 618 | return nil, err |
| 619 | } |
| 620 | shell := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, stderr) |
| 621 | |
| 622 | sysPrompt, err := cfg.ResolveSystemPromptForRoot(root) |
| 623 | if err != nil { |
| 624 | if !config.IsMissingSystemPromptFile(err) { |
| 625 | return nil, err |
| 626 | } |
| 627 | // A stale missing prompt file must not block startup: warn and fall back |
| 628 | // to the inline (or built-in default) system prompt. Other read failures |
| 629 | // stay fatal so Reasonix never runs without explicitly configured policy. |
| 630 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: err.Error() + "; falling back to inline/default system prompt"}) |
| 631 | sysPrompt = cfg.InlineSystemPrompt() |
| 632 | } |
| 633 | // Output style: fold the selected persona/tone block into the base prompt |
| 634 | // before language/memory/skills append, so a "replace" style (keep-coding |
| 635 | // false) still keeps those. Applied once, into the cache-stable prefix. |
| 636 | if st, ok := outputstyle.Resolve(cfg.Agent.OutputStyle, outputstyle.Dirs()); ok { |
| 637 | sysPrompt = outputstyle.Apply(sysPrompt, st) |
| 638 | } |
| 639 | sysPrompt = appendCorePolicies(sysPrompt) |
| 640 | sysPrompt += "\n\n" + sessioncontext.PolicyBlock() |
| 641 | sessionContextStatic := sessioncontext.Sections{Workspace: currentWorkspacePromptLine(root)} |
| 642 | // Execution modes no longer exist. Host obligations are fact-driven and |
| 643 | // never rewrite the cache-stable system prefix or tool schemas. |
| 644 | if cfg.EnvironmentEnabled() { |
| 645 | shellLabel := resolvedShellLabel(shell, cfg.Tools.Shell.Path) |
| 646 | envSection := environment.FormatSection( |
| 647 | environment.RunProbesWithOptions(ctx, environment.DefaultProbes(), environment.ProbeOptions{ |
| 648 | Overrides: cfg.Environment.Tools, |
| 649 | DenyRoots: []string{root}, |
| 650 | // Persist probe results across restarts so transient probe flaps do |
| 651 | // not generate needless session-context replacements. |
| 652 | SnapshotDir: config.CacheDir(), |
| 653 | }), |
| 654 | runtime.GOOS+"/"+runtime.GOARCH, |
| 655 | shellLabel, |
| 656 | cfg.Environment.Tools, |
| 657 | ) |
| 658 | sessionContextStatic.Environment = envSection |
| 659 | } |
| 660 | sessionContextStatic.Environment = appendOfflineEnvironmentNote(sessionContextStatic.Environment, cfg.Environment.Offline) |
| 661 | |
| 662 | // Stable memory policy and REASONIX.md / AGENTS.md standing instructions |
| 663 | // enter the system prompt. Pinned facts and the background index remain in |
| 664 | // the controller-owned session-context snapshot. |
| 665 | if _, err := memory.StoreFor(config.MemoryUserDir(), root).MigrateV2(); err != nil { |
| 666 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Memory metadata migration did not complete.", Detail: err.Error()}) |
| 667 | } |
| 668 | mem := memory.Load(memory.Options{CWD: root, UserDir: config.MemoryUserDir()}) |
| 669 | sysPrompt = memory.Compose(sysPrompt, mem) |
| 670 | |
| 671 | implicitSkillInvocation := cfg.ImplicitSkillInvocationEnabled() |
| 672 | // Production controllers own watchers; package fixtures opt out to avoid |
| 673 | // exhausting descriptors, while store watcher tests opt in explicitly. |
| 674 | watchSkills := !strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test") |
| 675 | // Skills: rediscovery skipped on no-op/interceptor/UI rebuilds when |
| 676 | // ReuseAssembly is retained from the previous BuildResult. |
| 677 | var skillStore *skill.Store |
| 678 | var skills []skill.Skill |
| 679 | var allSkillStore *skill.Store |
| 680 | var allSkills []skill.Skill |
| 681 | // Enabled and all-stores share one host-lifetime physical watch service. |
| 682 | skillWatchService := newSkillWatchService(watchSkills, opts.Stderr) |
| 683 | skillCleanup := func() { closeSkillsWithWatcher(skillStore, allSkillStore, &skillWatchService) } |
| 684 | skillsOwned := false |
| 685 | defer closeUnownedSkills(&skillsOwned, skillCleanup) |
| 686 | canReuseSkills := opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) && |
| 687 | opts.ReuseAssembly.ImplicitSkillInvocation == implicitSkillInvocation |
| 688 | if canReuseSkills { |
| 689 | skills = opts.ReuseAssembly.Skills |
| 690 | allSkills = skills |
| 691 | skillStore = skill.New(skill.Options{ProjectRoot: root, Stderr: io.Discard, Watch: watchSkills, WatchService: skillWatchService}) |
| 692 | allSkillStore = skillStore |
| 693 | if s := strings.TrimSpace(opts.ReuseAssembly.SystemPrompt); s != "" { |
| 694 | sysPrompt = s |
| 695 | } |
| 696 | } else { |
| 697 | skillStore = skill.New(skill.Options{ |
| 698 | ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), |
| 699 | PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), |
| 700 | DisabledNames: cfg.DisabledSkillNames(), MaxDepth: cfg.SkillMaxDepth(), Stderr: opts.Stderr, Watch: watchSkills, |
| 701 | WatchService: skillWatchService, |
| 702 | }) |
| 703 | skillStore.ConfigureInvocationPolicy("", nil) |
| 704 | skills = skillStore.List() |
| 705 | allSkillStore = skill.New(skill.Options{ProjectRoot: root, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), ExcludedPaths: cfg.SkillExcludedPaths(), MaxDepth: cfg.SkillMaxDepth(), Stderr: io.Discard, Watch: watchSkills, WatchService: skillWatchService}) |
| 706 | allSkills = allSkillStore.List() |
| 707 | if implicitSkillInvocation { |
| 708 | sysPrompt += "\n\n" + skill.InvocationPolicyBlock() |
| 709 | } |
| 710 | } |
| 711 | sysPrompt = config.ApplyOfficialDeepSeekV4ProPersona(sysPrompt, entry) |
| 712 | |
| 713 | reg := tool.NewRegistry() |
| 714 | writeRoots := cfg.WriteRootsForRoot(root) |
| 715 | writeRoots = appendUniquePaths(writeRoots, additionalDirs...) |
| 716 | if opts.WorkspaceOnly { |
| 717 | writeRoots = []string{root} |
| 718 | } |
| 719 | networkEnabled := cfg.Sandbox.Network |
| 720 | if opts.SandboxNetworkOverride != nil { |
| 721 | networkEnabled = *opts.SandboxNetworkOverride |
| 722 | } |
| 723 | bashMode := cfg.BashMode() |
| 724 | if override := strings.TrimSpace(opts.SandboxBashOverride); override != "" { |
| 725 | bashMode = override |
| 726 | } |
| 727 | forbidReadRoots := RuntimeForbidReadRoots(cfg, root) |
| 728 | // managedConfig names the Reasonix-owned config FILES (config.toml, |
| 729 | // compatibility TOMLs, legacy v0.x config.json) the file-writers may repair |
| 730 | // outside the workspace after a fresh per-write human approval. The bash |
| 731 | // OS-sandbox write roots deliberately stay unwidened: config repair goes |
| 732 | // through the approval-gated file tools, not raw shell writes. |
| 733 | managedConfig := builtin.NewManagedConfigPaths(config.ReasonixManagedConfigPaths()) |
| 734 | bashSpec := sandbox.Spec{Mode: bashMode, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Network: networkEnabled} |
| 735 | bashSpec.Shell = shell |
| 736 | // The session-data guard blocks agent writes into Reasonix's own session |
| 737 | // stores (they race the app's saves and surface as conflict-copy loops); |
| 738 | // explicit allow_write entries stay a sanctioned escape hatch. |
| 739 | allowWriteRoots := cfg.AllowWriteRoots() |
| 740 | if opts.WorkspaceOnly { |
| 741 | allowWriteRoots = nil |
| 742 | } |
| 743 | sessionGuard := builtin.NewSessionDataGuard(config.MemoryUserDir(), allowWriteRoots) |
| 744 | writeRootSet := sandbox.NewWritableRootSet(writeRoots) |
| 745 | bashSpec.ProtectedWriteRoots = sandbox.ProtectedWriteRoots(config.MemoryUserDir()) |
| 746 | if bashSpec.Mode == "enforce" && !sandbox.Available() { |
| 747 | fmt.Fprintln(stderr, "warning: "+sandbox.UnavailableMessage()) |
| 748 | } |
| 749 | searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, stderr) |
| 750 | bashTimeout := time.Duration(cfg.BashTimeoutSeconds()) * time.Second |
| 751 | enabledBuiltins := cfg.Tools.Enabled |
| 752 | readPathResolver := builtin.NewPathResolver() |
| 753 | sessionTemp, persistentShell := sessionManagers(opts) |
| 754 | // Register the full built-in inventory for use_capability dispatch. The |
| 755 | // provider-visible surface is narrowed later via SetProviderVisibleTools. |
| 756 | addBuiltins(reg, enabledBuiltins, writeRoots, writeRootSet, bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, forbidReadRoots, readPathResolver, sessionGuard, managedConfig, opts.FileOverlay, opts.TerminalRunner, sessionTemp, fileWriteReceipt) |
| 757 | bindPersistentShell(reg, persistentShell) |
| 758 | addWebSearch(reg, cfg, entry, proxySpec, sink) |
| 759 | browserExec, closeBrowser := browserBackend(opts.BrowserExecutor, cfg.Browser, writeRoots) |
| 760 | if browserExec != nil { |
| 761 | for _, t := range browser.Tools(browserExec) { |
| 762 | reg.Add(t) |
| 763 | } |
| 764 | } |
| 765 | // Use the caller-supplied shared host when set, so controllers for the same |
| 766 | // workspace root reuse running MCP processes (e.g. one CodeGraph daemon |
| 767 | // instead of one per tab). Otherwise construct a private host per controller. |
| 768 | pluginHost := opts.SharedHost |
| 769 | if pluginHost == nil { |
| 770 | pluginHost = plugin.NewHostWithProfile(opts.MCPHostProfile) |
| 771 | } |
| 772 | |
| 773 | // Enabled MCP servers enter the tool catalog at boot. Cached schemas |
| 774 | // register placeholders without starting processes; cache-miss servers get |
| 775 | // a single background catalog discovery. First real tool call uses |
| 776 | // EnsureConnected so parent/child/tab runtimes share one process. |
| 777 | pluginSpecOptions := PluginSpecOptions{ |
| 778 | DefaultStartupTimeout: time.Duration(cfg.MCPStartupTimeoutSeconds()) * time.Second, |
| 779 | DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second, |
| 780 | LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), root), |
| 781 | ConfigSource: "workspace_config", |
| 782 | StateHome: config.ReasonixHomeDir(), |
| 783 | WriterRoots: writeRoots, |
| 784 | ForbidReadRoots: forbidReadRoots, |
| 785 | Network: networkEnabled, |
| 786 | PackageOwners: pluginPackageOwners(cfg), |
| 787 | OAuthHTTPClient: balanceClient, |
| 788 | } |
| 789 | autoStartEntries := cfg.EnabledPlugins(root, config.DefaultMCPActivationStore()) |
| 790 | enabledMCPNames := make(map[string]bool, len(autoStartEntries)) |
| 791 | for _, enabled := range autoStartEntries { |
| 792 | if name := strings.TrimSpace(enabled.Name); name != "" { |
| 793 | enabledMCPNames[name] = true |
| 794 | } |
| 795 | } |
| 796 | // Legacy eager/background tiers are still parsed for config compatibility |
| 797 | // but no longer change process start timing. Keep the partition only so |
| 798 | // demotion notices remain meaningful for chronically slow eager configs. |
| 799 | eagerEntries, bgEntries := partitionByTier(autoStartEntries) |
| 800 | extraSpecs := applyDefaultMCPStartupTimeout( |
| 801 | applyDefaultMCPCallTimeout( |
| 802 | applyKnownPluginOverrides(opts.ExtraPlugins, root), |
| 803 | pluginSpecOptions.DefaultCallTimeout, |
| 804 | ), |
| 805 | pluginSpecOptions.DefaultStartupTimeout, |
| 806 | ) |
| 807 | for i := range extraSpecs { |
| 808 | if strings.TrimSpace(extraSpecs[i].WorkspaceRoot) == "" { |
| 809 | extraSpecs[i].WorkspaceRoot = root |
| 810 | } |
| 811 | if extraSpecs[i].LaunchManager == nil { |
| 812 | extraSpecs[i].LaunchManager = pluginSpecOptions.LaunchManager |
| 813 | } |
| 814 | if strings.TrimSpace(extraSpecs[i].ConfigSource) == "" { |
| 815 | extraSpecs[i].ConfigSource = "host_session" |
| 816 | } |
| 817 | if !extraSpecs[i].RequireLaunchApproval { |
| 818 | // Session-scoped MCP specs arrive through an explicit host/user action |
| 819 | // (for example ACP session/new), so they follow installed-server |
| 820 | // authorization without another per-tool or per-session prompt. |
| 821 | extraSpecs[i].Authorized = true |
| 822 | } |
| 823 | applyMCPIsolation(&extraSpecs[i], root, pluginSpecOptions) |
| 824 | } |
| 825 | // Auto-demote: any eager plugin that has been chronically slow (recent |
| 826 | // samples repeatedly hit the blocking startup budget) drops to background |
| 827 | // for this session. The user keeps eager intent, just doesn't pay for it |
| 828 | // on a server that's been misbehaving. A notice surfaces the demotion. |
| 829 | var demoteMessages []string |
| 830 | budget := plugin.DefaultStartupBudget() |
| 831 | kept := eagerEntries[:0] |
| 832 | for _, e := range eagerEntries { |
| 833 | rec := plugin.Recommend(e.Name, budget, 0) |
| 834 | if rec.Demote { |
| 835 | demoteMessages = append(demoteMessages, rec.Reason) |
| 836 | bgEntries = append(bgEntries, e) |
| 837 | continue |
| 838 | } |
| 839 | kept = append(kept, e) |
| 840 | } |
| 841 | eagerEntries = kept |
| 842 | |
| 843 | eagerSpecs := PluginSpecsForRootWithOptions(eagerEntries, root, pluginSpecOptions) |
| 844 | bgSpecs := PluginSpecsForRootWithOptions(bgEntries, root, pluginSpecOptions) |
| 845 | |
| 846 | eagerSpecs = append(eagerSpecs, extraSpecs...) |
| 847 | |
| 848 | // Apply caller-supplied stderr override to every spec across tiers. |
| 849 | if opts.Stderr != nil { |
| 850 | for i := range eagerSpecs { |
| 851 | eagerSpecs[i].Stderr = opts.Stderr |
| 852 | } |
| 853 | for i := range bgSpecs { |
| 854 | bgSpecs[i].Stderr = opts.Stderr |
| 855 | } |
| 856 | } |
| 857 | |
| 858 | // Host-session ExtraPlugins (for example ACP session servers) are explicit |
| 859 | // for this controller and still take a short readiness probe so recovery and |
| 860 | // session-scoped servers are deterministic. User/project config MCP stays |
| 861 | // catalog-first and process-idle until first real tool call. |
| 862 | if len(extraSpecs) > 0 { |
| 863 | for _, s := range extraSpecs { |
| 864 | if pluginHost.HasClient(s.Name) { |
| 865 | if tools, err := pluginHost.ToolsFor(ctx, s.Name); err == nil { |
| 866 | for _, t := range tools { |
| 867 | reg.Add(t) |
| 868 | } |
| 869 | continue |
| 870 | } |
| 871 | } |
| 872 | addCtx, addCancel := context.WithTimeout(ctx, 5*time.Second) |
| 873 | tools, err := pluginHost.EnsureConnectedWithLifecycle(ctx, addCtx, s, 0) |
| 874 | addCancel() |
| 875 | if err != nil { |
| 876 | if plugin.IsServerAlreadyConnected(err) { |
| 877 | if tools, err2 := pluginHost.ToolsFor(ctx, s.Name); err2 == nil { |
| 878 | for _, t := range tools { |
| 879 | reg.Add(t) |
| 880 | } |
| 881 | continue |
| 882 | } |
| 883 | } |
| 884 | // Leave a catalog entry for diagnostics; failures surface in /mcp. |
| 885 | cs, _ := plugin.LoadCachedSchemaForSpec(s) |
| 886 | for _, t := range plugin.LazyToolset(s, cs, pluginHost, reg, ctx, false) { |
| 887 | reg.Add(t) |
| 888 | } |
| 889 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, |
| 890 | Text: "An MCP server failed to start.", Detail: fmt.Sprintf("mcp %s: %v", s.Name, err)}) |
| 891 | continue |
| 892 | } |
| 893 | for _, t := range tools { |
| 894 | reg.Add(t) |
| 895 | } |
| 896 | } |
| 897 | } |
| 898 | |
| 899 | // Configured enabled MCP: cache-hit placeholders without starting processes; |
| 900 | // cache-miss servers get one background catalog discovery. |
| 901 | registerEnabledMCP := func(specs []plugin.Spec) { |
| 902 | for _, s := range specs { |
| 903 | if pluginHost.HasClient(s.Name) { |
| 904 | tools, err := pluginHost.ToolsFor(ctx, s.Name) |
| 905 | if err == nil { |
| 906 | for _, t := range tools { |
| 907 | reg.Add(t) |
| 908 | } |
| 909 | continue |
| 910 | } |
| 911 | } |
| 912 | cs, _ := plugin.LoadCachedSchemaForSpec(s) |
| 913 | // Only kick a process for catalog discovery when no usable schema is |
| 914 | // cached. Cache-hit sessions stay process-idle until first tool call. |
| 915 | kick := cs == nil || len(cs.Tools) == 0 |
| 916 | for _, t := range plugin.LazyToolset(s, cs, pluginHost, reg, ctx, kick) { |
| 917 | reg.Add(t) |
| 918 | } |
| 919 | } |
| 920 | } |
| 921 | // eagerSpecs already includes extraSpecs; avoid double |
| 922 | // registration of host-session servers that connected above. |
| 923 | configSpecs := append(append([]plugin.Spec{}, eagerSpecs...), bgSpecs...) |
| 924 | if len(extraSpecs) > 0 { |
| 925 | extraNames := map[string]bool{} |
| 926 | for _, s := range extraSpecs { |
| 927 | extraNames[s.Name] = true |
| 928 | } |
| 929 | filtered := configSpecs[:0] |
| 930 | for _, s := range configSpecs { |
| 931 | if extraNames[s.Name] { |
| 932 | continue |
| 933 | } |
| 934 | filtered = append(filtered, s) |
| 935 | } |
| 936 | configSpecs = filtered |
| 937 | } |
| 938 | registerEnabledMCP(configSpecs) |
| 939 | |
| 940 | for _, msg := range demoteMessages { |
| 941 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: msg}) |
| 942 | } |
| 943 | |
| 944 | cleanup := func() { |
| 945 | skillCleanup() |
| 946 | pluginHost.Close() |
| 947 | } |
| 948 | if opts.SharedHost != nil { |
| 949 | // The caller owns the shared host's lifecycle; the controller must not |
| 950 | // close it. A no-op cleanup keeps Controller.Close happy without |
| 951 | // shutting down MCP processes that other controllers still use. |
| 952 | cleanup = skillCleanup |
| 953 | } |
| 954 | |
| 955 | // addTools registers tools on reg and returns the names that were added. |
| 956 | addTools := func(reg *tool.Registry, tools []tool.Tool) []string { |
| 957 | names := make([]string, 0, len(tools)) |
| 958 | for _, t := range tools { |
| 959 | if t == nil { |
| 960 | continue |
| 961 | } |
| 962 | reg.Add(t) |
| 963 | names = append(names, t.Name()) |
| 964 | } |
| 965 | return names |
| 966 | } |
| 967 | |
| 968 | // LSP tools resolve their servers on PATH and spawn lazily on first query, so |
| 969 | // registering them is cheap even when no server is installed (a query then |
| 970 | // returns an install hint). The manager is session-scoped; chain its shutdown |
| 971 | // into the controller's cleanup so servers stop with the session, not the turn. |
| 972 | var lspMgr *lsp.Manager |
| 973 | lspToolsAdded := false |
| 974 | addLSPTools := func() []string { |
| 975 | if lspMgr == nil || lspToolsAdded { |
| 976 | return nil |
| 977 | } |
| 978 | lspToolsAdded = true |
| 979 | return addTools(reg, lsp.Tools(lspMgr)) |
| 980 | } |
| 981 | if cfg.LSP.Enabled { |
| 982 | lspMgr = lsp.NewManager(root, LSPSpecs(cfg.LSP)) |
| 983 | addLSPTools() |
| 984 | prev := cleanup |
| 985 | cleanup = func() { prev(); lspMgr.Close() } |
| 986 | } |
| 987 | |
| 988 | maxSteps := max(opts.MaxSteps, 0) |
| 989 | subagentStore, err := newSubagentStore(sessionDir, opts.SubagentParentLive) |
| 990 | if err != nil { |
| 991 | return nil, err |
| 992 | } |
| 993 | if subagentStore != nil { |
| 994 | subagentStore.WithDestroyedChecker(jm.IsDestroying) |
| 995 | } |
| 996 | |
| 997 | // Permission policy gates every tool call. With no HeadlessApprovalMode |
| 998 | // (interactive bootstrap), the temporary gate preserves the legacy behavior |
| 999 | // until chat/desktop installs an interactive gate. A real headless caller |
| 1000 | // such as `reasonix run` always supplies a mode: Ask fails closed, Auto |
| 1001 | // allows ordinary writer fallbacks, and DontAsk denies them (#6927). |
| 1002 | // The selected contract is also applied to sub-agents, so they cannot be a |
| 1003 | // weaker path around the parent gate. |
| 1004 | // Sub-agents always run headless: they have no UI to answer a prompt, so they |
| 1005 | // inherit this same gate. |
| 1006 | policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny). |
| 1007 | WithSessionAllow(opts.PermissionAllow) |
| 1008 | headlessGate := control.NewSharedHeadlessGate(policy, opts.HeadlessApprovalMode) |
| 1009 | |
| 1010 | var resolvedHooks []hook.ResolvedHook |
| 1011 | if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) { |
| 1012 | resolvedHooks = opts.ReuseAssembly.Hooks |
| 1013 | } else { |
| 1014 | resolvedHooks = hook.Load(hook.LoadOptions{ProjectRoot: root}) |
| 1015 | } |
| 1016 | hookRuntime := hook.RuntimeOptions{} |
| 1017 | if shell.Kind == sandbox.ShellBash { |
| 1018 | hookRuntime.BashPath = shell.Path |
| 1019 | } |
| 1020 | hookRunner := hook.NewRunner( |
| 1021 | resolvedHooks, root, hook.NewDefaultSpawner(hookRuntime), |
| 1022 | func(msg string) { sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg}) }, |
| 1023 | ) |
| 1024 | // The `task` tool spawns sub-agents that reuse the parent's provider and |
| 1025 | // tool registry. Wired here after the built-ins / plugins are loaded so |
| 1026 | // sub-agents inherit the full tool set (minus `task` itself, to keep |
| 1027 | // nesting out of the picture). It registers into the same reg the |
| 1028 | // executor uses, so the model surfaces it like any other tool. |
| 1029 | resolveSubagentProvider := func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) { |
| 1030 | me := *entry |
| 1031 | selectedRef := modelRefFromEntry(entry) |
| 1032 | if strings.TrimSpace(modelRef) != "" { |
| 1033 | if resolved, ok := cfg.ResolveModel(modelRef); ok { |
| 1034 | me = *resolved |
| 1035 | selectedRef = modelRefFromEntry(resolved) |
| 1036 | } else if effectiveResolver != nil { |
| 1037 | me = *syntheticEntryFromResolver(effectiveResolver, modelRef) |
| 1038 | selectedRef = modelRef |
| 1039 | } else { |
| 1040 | return nil, nil, 0, fmt.Errorf("unknown model %q", modelRef) |
| 1041 | } |
| 1042 | } |
| 1043 | var effortOverride *string |
| 1044 | if strings.TrimSpace(effort) != "" { |
| 1045 | normalized, err := config.NormalizeEffort(&me, effort) |
| 1046 | if err != nil { |
| 1047 | if effectiveResolver == nil { |
| 1048 | return nil, nil, 0, err |
| 1049 | } |
| 1050 | normalized = effort |
| 1051 | } |
| 1052 | me.Effort = normalized |
| 1053 | effortOverride = &normalized |
| 1054 | if me.Kind == "anthropic" && strings.TrimSpace(me.Effort) != "" && strings.TrimSpace(me.Thinking) == "" { |
| 1055 | me.Thinking = "adaptive" |
| 1056 | } |
| 1057 | } |
| 1058 | p, err := resolveProvider(effectiveResolver, cfg, proxySpec, provider.Selection{Ref: selectedRef, Effort: effortOverride}) |
| 1059 | if err != nil { |
| 1060 | return nil, nil, 0, err |
| 1061 | } |
| 1062 | return p, me.Price, me.ContextWindow, nil |
| 1063 | } |
| 1064 | subagentIdentity := func(modelRef, effort string) (string, string) { |
| 1065 | return subagentEffectiveIdentity(cfg, opts.ProviderResolver, modelName, entry, modelRef, effort) |
| 1066 | } |
| 1067 | taskModel := firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel) |
| 1068 | taskEffort := firstNonEmpty(cfg.Agent.SubagentEfforts["task"], cfg.Agent.SubagentEffort) |
| 1069 | maxSubagentDepth := agent.NormalizeMaxSubagentDepth(cfg.Agent.MaxSubagentDepth) |
| 1070 | maxSubagentConcurrency, maxParallelWriters := agent.NormalizeConcurrencyLimits( |
| 1071 | cfg.Agent.MaxSubagentConcurrency, cfg.Agent.MaxParallelWriters, |
| 1072 | ) |
| 1073 | subagentScheduler := agent.NewSubagentScheduler(maxSubagentConcurrency, maxParallelWriters) |
| 1074 | profileLookup := func(name string) (agent.ProfileDefinition, bool) { |
| 1075 | sk, ok := skillStore.Read(name) |
| 1076 | if !ok || sk.RunAs != skill.RunSubagent { |
| 1077 | return agent.ProfileDefinition{}, false |
| 1078 | } |
| 1079 | return agent.ProfileFromSkill(skillStore.Prepare(sk)), true |
| 1080 | } |
| 1081 | profileConfigModel := func(profile string) string { |
| 1082 | for _, key := range SubagentModelKeys(profile) { |
| 1083 | if m := strings.TrimSpace(cfg.Agent.SubagentModels[key]); m != "" { |
| 1084 | return m |
| 1085 | } |
| 1086 | } |
| 1087 | return "" |
| 1088 | } |
| 1089 | profileConfigEffort := func(profile string) string { |
| 1090 | for _, key := range SubagentModelKeys(profile) { |
| 1091 | if e := strings.TrimSpace(cfg.Agent.SubagentEfforts[key]); e != "" { |
| 1092 | return e |
| 1093 | } |
| 1094 | } |
| 1095 | return "" |
| 1096 | } |
| 1097 | bashSandboxEnforced := bashSpec.Enforce |
| 1098 | taskToolAdded := false |
| 1099 | readOnlyTaskToolAdded := false |
| 1100 | var taskTool *agent.TaskTool |
| 1101 | // capRuntime is assigned after MCP specs load; closures capture the variable |
| 1102 | // so task tools created later still receive the session-shared substrate. |
| 1103 | var capRuntime *agent.MCPCapabilityRuntime |
| 1104 | visionProviderResolver := func(ref string) (provider.Provider, error) { |
| 1105 | ve, ok := resolveOptionalEntry(effectiveResolver, cfg, strings.TrimSpace(ref)) |
| 1106 | if !ok || ve == nil || strings.TrimSpace(ve.Model) == "" { |
| 1107 | return nil, fmt.Errorf("unknown vision model %q", ref) |
| 1108 | } |
| 1109 | return resolveProvider(effectiveResolver, cfg, proxySpec, provider.Selection{Ref: modelRefFromEntry(ve)}) |
| 1110 | } |
| 1111 | visionModelSelector := func(currentRef, _ string) (string, bool) { |
| 1112 | current, ok := resolveOptionalEntry(effectiveResolver, cfg, strings.TrimSpace(currentRef)) |
| 1113 | if !ok || current == nil { |
| 1114 | return "", false |
| 1115 | } |
| 1116 | for i := range cfg.Providers { |
| 1117 | p := &cfg.Providers[i] |
| 1118 | if p.Name != current.Name || !p.Configured() { |
| 1119 | continue |
| 1120 | } |
| 1121 | models := p.ModelList() |
| 1122 | ordered := make([]string, 0, len(models)) |
| 1123 | if d := p.DefaultModel(); d != "" { |
| 1124 | ordered = append(ordered, d) |
| 1125 | } |
| 1126 | for _, model := range models { |
| 1127 | if model != "" && model != p.DefaultModel() { |
| 1128 | ordered = append(ordered, model) |
| 1129 | } |
| 1130 | } |
| 1131 | for _, model := range ordered { |
| 1132 | candidate, found := cfg.ResolveModel(p.Name + "/" + model) |
| 1133 | if found && candidate.Configured() && modelCapabilities.Resolve(candidate).State == config.CapabilitySupported { |
| 1134 | return candidate.Name + "/" + candidate.Model, true |
| 1135 | } |
| 1136 | } |
| 1137 | } |
| 1138 | return "", false |
| 1139 | } |
| 1140 | |
| 1141 | imageConfig := &imageinput.Config{Model: cfg.Agent.VisionModel, Resolve: visionProviderResolver, Select: visionModelSelector} |
| 1142 | newTaskTool := func() *agent.TaskTool { |
| 1143 | return agent.NewTaskToolWithOptions(agent.TaskToolOptions{ |
| 1144 | ImageInput: imageConfig, |
| 1145 | Provider: execProv, |
| 1146 | Pricing: entry.Price, |
| 1147 | QuoteContext: quoteCtx, |
| 1148 | ParentRegistry: reg, |
| 1149 | MaxSteps: maxSteps, |
| 1150 | ContextWindow: entry.ContextWindow, |
| 1151 | RecentKeep: cfg.Agent.RecentKeep, |
| 1152 | SoftCompactRatio: cfg.Agent.SoftCompactRatio, |
| 1153 | ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio, |
| 1154 | CompactRatio: cfg.Agent.CompactRatio, |
| 1155 | CompactForceRatio: cfg.Agent.CompactForceRatio, |
| 1156 | ContextEditing: cfg.Agent.ContextEditing, |
| 1157 | Temperature: cfg.Agent.Temperature, |
| 1158 | ArchiveDir: config.ArchiveDir(), |
| 1159 | SysPrompt: "", |
| 1160 | Gate: headlessGate, |
| 1161 | KeepPolicy: keepPolicy, |
| 1162 | SubagentModel: taskModel, |
| 1163 | SubagentEffort: taskEffort, |
| 1164 | ResolveProvider: resolveSubagentProvider, |
| 1165 | }). |
| 1166 | WithTranscripts(subagentStore, root, modelName, entry.Effort). |
| 1167 | WithTranscriptIdentityResolver(subagentIdentity). |
| 1168 | WithMaxSubagentDepth(maxSubagentDepth). |
| 1169 | WithAblation(opts.Ablation). |
| 1170 | WithWorkspaceLease(workspaceLease). |
| 1171 | WithScheduler(subagentScheduler). |
| 1172 | WithProfileLookup(profileLookup). |
| 1173 | WithProfileConfigResolvers(profileConfigModel, profileConfigEffort). |
| 1174 | WithBashSandboxEnforced(bashSandboxEnforced). |
| 1175 | WithCapabilityRuntime(capRuntime). |
| 1176 | WithWriteRoots(writeRootSet).WithImageRequestResolver(controllerImageResolver{ctrlRef.Load}) |
| 1177 | } |
| 1178 | addTaskTool := func() string { |
| 1179 | if opts.Ablation.Off(ablation.Subagent) { |
| 1180 | return "task tool is disabled for this run." |
| 1181 | } |
| 1182 | if taskToolAdded { |
| 1183 | return "task tool is already enabled." |
| 1184 | } |
| 1185 | taskToolAdded = true |
| 1186 | if taskTool == nil { |
| 1187 | taskTool = newTaskTool() |
| 1188 | } |
| 1189 | // The registry exports schemas in stable name order. Keep this surface |
| 1190 | // static: profile names and result refs never enter provider-visible |
| 1191 | // schemas, and the result reader does not change between turns. |
| 1192 | reg.Add(taskTool) |
| 1193 | reg.Add(agent.NewParallelTasksTool(taskTool, reg)) |
| 1194 | reg.Add(agent.NewFleetTool(taskTool)) |
| 1195 | reg.Add(agent.NewSubagentResultTool(taskTool)) |
| 1196 | return "enabled task." |
| 1197 | } |
| 1198 | addReadOnlyTaskTool := func() string { |
| 1199 | if opts.Ablation.Off(ablation.Subagent) { |
| 1200 | return "read_only_task tool is disabled for this run." |
| 1201 | } |
| 1202 | if readOnlyTaskToolAdded { |
| 1203 | return "read_only_task tool is already enabled." |
| 1204 | } |
| 1205 | readOnlyTaskToolAdded = true |
| 1206 | if taskTool == nil { |
| 1207 | taskTool = newTaskTool() |
| 1208 | } |
| 1209 | reg.Add(agent.NewReadOnlyTaskTool(taskTool)) |
| 1210 | return "enabled read_only_task." |
| 1211 | } |
| 1212 | addTaskTool() |
| 1213 | addReadOnlyTaskTool() |
| 1214 | |
| 1215 | // Product documentation, session, and memory tools are always present on the |
| 1216 | // unified host registry for every role setting. Provider-visible surface stays |
| 1217 | // lean via use_capability; these tools are dispatchable without schema growth. |
| 1218 | docsToolAdded := false |
| 1219 | addDocsTool := func() string { |
| 1220 | if docsToolAdded { |
| 1221 | return "docs is already enabled." |
| 1222 | } |
| 1223 | docsToolAdded = true |
| 1224 | reg.Add(productdocs.NewTool()) |
| 1225 | return "enabled docs." |
| 1226 | } |
| 1227 | sessionToolsAdded := false |
| 1228 | addSessionTools := func() string { |
| 1229 | if sessionToolsAdded { |
| 1230 | return "sessions are already enabled." |
| 1231 | } |
| 1232 | sessionToolsAdded = true |
| 1233 | // history and memory are the BM25-backed surfaces; the ablation arm drops |
| 1234 | // only those two and leaves the direct-access tools alone, so a lost solve |
| 1235 | // is attributable to retrieval and not to a missing session reader. |
| 1236 | if opts.Ablation.Off(ablation.Retrieval) { |
| 1237 | reg.Add(sessiontool.NewListSessionsTool(sessionDir)) |
| 1238 | reg.Add(sessiontool.NewReadSessionTool(sessionDir)) |
| 1239 | return "enabled list_sessions, read_session." |
| 1240 | } |
| 1241 | reg.Add(history.NewIndexedTool(history.Options{SessionDir: sessionDir, GlobalSessionDir: config.SessionDir(), ArchiveDir: config.ArchiveDir()})) |
| 1242 | reg.Add(sessiontool.NewListSessionsTool(sessionDir)) |
| 1243 | reg.Add(sessiontool.NewReadSessionTool(sessionDir)) |
| 1244 | return "enabled history, list_sessions, read_session." |
| 1245 | } |
| 1246 | memoryToolsAdded := false |
| 1247 | addMemoryTools := func() string { |
| 1248 | if memoryToolsAdded { |
| 1249 | return "memory tools are already enabled." |
| 1250 | } |
| 1251 | memoryToolsAdded = true |
| 1252 | if opts.Ablation.Off(ablation.Retrieval) { |
| 1253 | reg.Add(memory.NewRememberTool(mem.Store)) |
| 1254 | reg.Add(memory.NewForgetTool(mem.Store)) |
| 1255 | return "enabled remember, forget." |
| 1256 | } |
| 1257 | reg.Add(memory.NewRecallTool(mem.Store)) |
| 1258 | reg.Add(memory.NewRememberTool(mem.Store)) |
| 1259 | reg.Add(memory.NewForgetTool(mem.Store)) |
| 1260 | return "enabled memory, remember, forget." |
| 1261 | } |
| 1262 | addDocsTool() |
| 1263 | addSessionTools() |
| 1264 | addMemoryTools() |
| 1265 | |
| 1266 | // The `ask` tool puts structured multiple-choice questions to the user. It |
| 1267 | // reaches them through the Asker on the call context, which interactive |
| 1268 | // frontends wire to the controller (EnableInteractiveApproval); a headless run |
| 1269 | // has none, so ask resolves to "decide for yourself". |
| 1270 | registerInteractiveAgentTools(reg) |
| 1271 | |
| 1272 | // Skill tools: read_only_skill is a narrow explicitly read-only entry point; the |
| 1273 | // full skills source adds run_skill / install_skill plus the dedicated |
| 1274 | // subagent wrappers (explore / research / review / security_review). Read-only |
| 1275 | // subagent skills run ephemerally with the same registry boundary as |
| 1276 | // read_only_task, so they cannot write, install, mutate memory, resume/fork |
| 1277 | // transcripts, or delegate further. |
| 1278 | // |
| 1279 | subagentSkillOptions := newSubagentSkillOptionsFactory(cfg.Agent, quoteCtx, headlessGate, keepPolicy, maxSubagentDepth, opts.Ablation, workspaceLease, writeRootSet, childImageRouting{ctrlRef.Load, imageConfig}) |
| 1280 | readOnlySkillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) { |
| 1281 | if strings.TrimSpace(runOpts.ContinueFrom) != "" || strings.TrimSpace(runOpts.ForkFrom) != "" { |
| 1282 | return "", fmt.Errorf("read_only_skill does not support continue_from/fork_from") |
| 1283 | } |
| 1284 | releaseSlot, err := subagentScheduler.Acquire(sctx, agent.AcquireRequest{ |
| 1285 | Writer: false, |
| 1286 | Nested: agent.SubagentDepth(sctx) > 0, |
| 1287 | Label: sk.Name, |
| 1288 | }) |
| 1289 | if err != nil { |
| 1290 | return "", err |
| 1291 | } |
| 1292 | defer releaseSlot() |
| 1293 | sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg)) |
| 1294 | prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow |
| 1295 | modelRef := subagentModelRef(cfg, sk) |
| 1296 | effortRef := subagentEffortRef(cfg, sk) |
| 1297 | if modelRef != "" || effortRef != "" { |
| 1298 | p, pr, cw, err := resolveSubagentProvider(modelRef, effortRef) |
| 1299 | if err != nil { |
| 1300 | return "", fmt.Errorf("read-only subagent skill %q profile: %w", sk.Name, err) |
| 1301 | } |
| 1302 | prov, price, ctxWin = p, pr, cw |
| 1303 | } |
| 1304 | childDepth := agent.SubagentDepth(sctx) + 1 |
| 1305 | if childDepth > maxSubagentDepth { |
| 1306 | return "", fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxSubagentDepth) |
| 1307 | } |
| 1308 | subReg := agent.ReadOnlySubagentToolRegistryForDepthWithRuntime(reg, sk.AllowedTools, childDepth, maxSubagentDepth, capRuntime) |
| 1309 | if subReg.Len() == 0 { |
| 1310 | return "", fmt.Errorf("read_only_skill: skill %q has no read-only tools available", sk.Name) |
| 1311 | } |
| 1312 | steps := maxSteps |
| 1313 | if steps > 0 { |
| 1314 | if steps /= 2; steps < 5 { |
| 1315 | steps = 5 |
| 1316 | } |
| 1317 | } |
| 1318 | // Custom and named built-in profiles fully control their system prompt |
| 1319 | // (no implicit concise/DefaultReadOnlyTaskSystemPrompt overlay). |
| 1320 | sysPrompt := strings.TrimSpace(sk.Body) |
| 1321 | if sysPrompt == "" { |
| 1322 | sysPrompt = agent.DefaultReadOnlyTaskSystemPrompt |
| 1323 | } |
| 1324 | task, runOptions := reviewSubagentSkillOptions(sctx, sk.Name, task, steps, price, ctxWin, childDepth, subagentSkillOptions) |
| 1325 | usageModelRef, _ := subagentIdentity(modelRef, effortRef) |
| 1326 | runOptions.ModelRef = usageModelRef |
| 1327 | // Review gates consume typed, host-verifiable reports so a review |
| 1328 | // cannot end in unverifiable prose. Review skills run only for |
| 1329 | // mid/high-risk work under the standard policy. |
| 1330 | runOptions.RequireReviewReportKind = "" |
| 1331 | // Provider serializers decide whether these images are wire-visible from |
| 1332 | // the child model's own vision capability. Text-only children retain the |
| 1333 | // attachment metadata locally but never receive image parts on the wire. |
| 1334 | childCtx := agent.WithUserImages(sctx, agent.SubagentImageCandidates(sctx)) |
| 1335 | return runReadOnlySkillSession(childCtx, prov, subReg, task, runOptions, agent.NestedSink(sctx, event.Discard), sysPrompt, agent.RunReadOnlySubAgentWithSession) |
| 1336 | } |
| 1337 | // Writer-capable subagent skills reuse the sub-agent machinery via this |
| 1338 | // runner: an isolated loop with the skill body as system prompt, a tool set |
| 1339 | // scoped to the skill's allowed-tools (minus recursive meta-tools), optional |
| 1340 | // per-skill model, and resumable transcripts when the parent session supports |
| 1341 | // them. Its tool activity nests under the invoking call, like `task`. |
| 1342 | skillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) { |
| 1343 | // Writer skills without write_paths claim the whole workspace so they |
| 1344 | // cannot race fleet/task writers that declared disjoint paths. |
| 1345 | acq := agent.AcquireRequest{ |
| 1346 | Writer: !sk.ReadOnly, |
| 1347 | Nested: agent.SubagentDepth(sctx) > 0, |
| 1348 | Label: sk.Name, |
| 1349 | } |
| 1350 | if !sk.ReadOnly { |
| 1351 | whole, werr := agent.WholeWorkspaceWriteClaim(root) |
| 1352 | if werr != nil { |
| 1353 | return "", fmt.Errorf("subagent skill %q write claim: %w", sk.Name, werr) |
| 1354 | } |
| 1355 | acq.WritePaths = whole |
| 1356 | } |
| 1357 | releaseSlot, err := subagentScheduler.Acquire(sctx, acq) |
| 1358 | if err != nil { |
| 1359 | return "", err |
| 1360 | } |
| 1361 | defer releaseSlot() |
| 1362 | sk = skill.WithCodeGraphTools(sk, skill.CodeGraphReadTools(reg)) |
| 1363 | prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow |
| 1364 | modelRef := subagentModelRef(cfg, sk) |
| 1365 | effortRef := subagentEffortRef(cfg, sk) |
| 1366 | if modelRef != "" || effortRef != "" { |
| 1367 | p, pr, cw, err := resolveSubagentProvider(modelRef, effortRef) |
| 1368 | if err != nil { |
| 1369 | return "", fmt.Errorf("subagent skill %q profile: %w", sk.Name, err) |
| 1370 | } |
| 1371 | prov, price, ctxWin = p, pr, cw |
| 1372 | } |
| 1373 | childDepth := agent.SubagentDepth(sctx) + 1 |
| 1374 | if childDepth > maxSubagentDepth { |
| 1375 | return "", fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxSubagentDepth) |
| 1376 | } |
| 1377 | // A read-only skill (builtin review/security-review, or frontmatter |
| 1378 | // `read-only: true`) gets its promise enforced at the tool boundary: |
| 1379 | // writer tools are stripped and bash runs under the read-only |
| 1380 | // command policy. Transcripts recorded against the writer-capable |
| 1381 | // registry stop matching on continue_from (schema-hash check reports |
| 1382 | // the mismatch). |
| 1383 | subReg, childWriteRoots := skillSubagentRegistry(sk, reg, childDepth, maxSubagentDepth, capRuntime, writeRootSet) |
| 1384 | continueFrom := strings.TrimSpace(runOpts.ContinueFrom) |
| 1385 | legacyForkFrom := strings.TrimSpace(runOpts.ForkFrom) |
| 1386 | if continueFrom != "" && legacyForkFrom != "" { |
| 1387 | return "", fmt.Errorf("continue_from and fork_from are mutually exclusive; pass only continue_from") |
| 1388 | } |
| 1389 | parentID, parentSink, _, _ := agent.CallContext(sctx) |
| 1390 | if runOpts.HostInitiated { |
| 1391 | parentID = "" |
| 1392 | } |
| 1393 | parentSession := agent.ParentSession(sctx) |
| 1394 | var run *agent.SubagentRun |
| 1395 | if subagentStore == nil || parentSession == "" { |
| 1396 | // Headless runs (e.g. `reasonix run`) have no persistent session to |
| 1397 | // own a transcript. Run the skill sub-agent ephemerally, as before |
| 1398 | // persisted transcripts existed, instead of failing. Continuation needs |
| 1399 | // a persisted owner, so it errors here. |
| 1400 | if continueFrom != "" || legacyForkFrom != "" { |
| 1401 | return "", fmt.Errorf("subagent continuation requires a persisted session; none is active in this run") |
| 1402 | } |
| 1403 | run = agent.EphemeralSubagentRun(sk.Body) |
| 1404 | } else { |
| 1405 | identityModel, identityEffort := subagentIdentity(modelRef, effortRef) |
| 1406 | spec := agent.SubagentSpec{ |
| 1407 | Kind: "skill", |
| 1408 | Name: sk.Name, |
| 1409 | WorkspaceRoot: root, |
| 1410 | ParentSession: parentSession, |
| 1411 | ParentToolCallID: parentID, |
| 1412 | SystemPrompt: sk.Body, |
| 1413 | Registry: subReg, |
| 1414 | Model: identityModel, |
| 1415 | Effort: identityEffort, |
| 1416 | } |
| 1417 | var prepErr error |
| 1418 | if continueFrom != "" { |
| 1419 | run, prepErr = subagentStore.PrepareContinue(continueFrom, spec) |
| 1420 | } else if legacyForkFrom != "" { |
| 1421 | run, prepErr = subagentStore.PrepareLegacyForkFrom(legacyForkFrom, spec) |
| 1422 | } else { |
| 1423 | run, prepErr = subagentStore.PrepareFresh(spec) |
| 1424 | } |
| 1425 | if prepErr != nil { |
| 1426 | return "", prepErr |
| 1427 | } |
| 1428 | } |
| 1429 | defer run.Release() |
| 1430 | steps := maxSteps |
| 1431 | if steps > 0 { |
| 1432 | if steps /= 2; steps < 5 { |
| 1433 | steps = 5 |
| 1434 | } |
| 1435 | } |
| 1436 | task, runOptions := reviewSubagentSkillOptions(sctx, sk.Name, task, steps, price, ctxWin, childDepth, subagentSkillOptions) |
| 1437 | runOptions.WriteRoots = childWriteRoots |
| 1438 | usageModelRef, _ := subagentIdentity(modelRef, effortRef) |
| 1439 | runOptions.ModelRef = usageModelRef |
| 1440 | announceSkillSubagentStart(parentSink, parentID, sk.Name, usageModelRef, effortRef, run, continueFrom != "" || legacyForkFrom != "") |
| 1441 | // Review gates consume typed, host-verifiable reports so a review |
| 1442 | // cannot end in unverifiable prose. Review skills run only for |
| 1443 | // mid/high-risk work under the standard policy. |
| 1444 | runOptions.RequireReviewReportKind = "" |
| 1445 | var answer string |
| 1446 | // The child provider owns the final vision decision, as in read-only runs. |
| 1447 | childCtx := agent.WithUserImages(sctx, agent.SubagentImageCandidates(sctx)) |
| 1448 | agent.EmitSubagentLifecycle(parentSink, "child_running", parentID, sk.Name, usageModelRef, effortRef, run, nil) |
| 1449 | if sk.ReadOnly { |
| 1450 | answer, err = agent.RunReadOnlySubAgentWithSession(childCtx, prov, subReg, run.Session, task, |
| 1451 | runOptions, agent.NestedSink(sctx, event.Discard)) |
| 1452 | } else { |
| 1453 | answer, err = agent.RunSubAgentWithSession(childCtx, prov, subReg, run.Session, task, |
| 1454 | runOptions, agent.NestedSink(sctx, event.Discard)) |
| 1455 | } |
| 1456 | if err != nil { |
| 1457 | return finishSkillSubagentFailure(sctx, taskTool, subagentStore, parentSink, parentID, sk.Name, usageModelRef, effortRef, task, run, err) |
| 1458 | } |
| 1459 | if err := saveSubagentCompleted(subagentStore, run); err != nil { |
| 1460 | return finishSkillSubagentFailure(sctx, taskTool, subagentStore, parentSink, parentID, sk.Name, usageModelRef, effortRef, task, run, err) |
| 1461 | } |
| 1462 | agent.EmitSubagentLifecycle(parentSink, "child_completed", parentID, sk.Name, usageModelRef, effortRef, run, &agent.SubagentOutcome{Status: agent.SubagentOutcomeCompleted, FinalAnswer: answer}) |
| 1463 | return agent.FormatSubagentRunResult(answer, run, false), nil |
| 1464 | } |
| 1465 | skillProfile := func(sk skill.Skill) *event.Profile { |
| 1466 | model, effort := subagentModelRef(cfg, sk), subagentEffortRef(cfg, sk) |
| 1467 | if model == "" && effort == "" { |
| 1468 | return nil |
| 1469 | } |
| 1470 | return &event.Profile{Model: model, Effort: effort} |
| 1471 | } |
| 1472 | var cmds []command.Command |
| 1473 | if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) { |
| 1474 | cmds = opts.ReuseAssembly.Commands |
| 1475 | } else { |
| 1476 | cmds, _ = command.LoadRoots(config.CommandRootsForRoot(root)...) |
| 1477 | } |
| 1478 | slashCommandAdded := false |
| 1479 | slashCommandIncludesSkills := false |
| 1480 | addSlashCommandTool := func(includeSkills bool) string { |
| 1481 | if slashCommandAdded && (!includeSkills || slashCommandIncludesSkills) { |
| 1482 | return "slash commands are already enabled." |
| 1483 | } |
| 1484 | // Expose loaded slash commands to the model via slash_command. In economy |
| 1485 | // mode skills join this list only after the skills source is enabled. |
| 1486 | var slashEntries []command.SlashEntry |
| 1487 | if includeSkills && implicitSkillInvocation { |
| 1488 | for _, sk := range skillStore.SlashList() { |
| 1489 | slashEntries = append(slashEntries, command.SlashEntry{ |
| 1490 | Name: sk.SlashName(), |
| 1491 | Description: sk.Description, |
| 1492 | Render: func(args []string) string { return skillStore.Render(sk, strings.Join(args, " ")) }, |
| 1493 | }) |
| 1494 | } |
| 1495 | } |
| 1496 | for _, cmd := range cmds { |
| 1497 | if cmd.Hidden { |
| 1498 | continue |
| 1499 | } |
| 1500 | |
| 1501 | slashEntries = append(slashEntries, command.SlashEntry{ |
| 1502 | Name: cmd.Name, |
| 1503 | Description: cmd.Description, |
| 1504 | ArgHint: cmd.ArgHint, |
| 1505 | Render: func(args []string) string { return cmd.Render(args) }, |
| 1506 | }) |
| 1507 | } |
| 1508 | reg.Add(command.NewSlashCommandTool(slashEntries)) |
| 1509 | slashCommandAdded = true |
| 1510 | slashCommandIncludesSkills = slashCommandIncludesSkills || includeSkills |
| 1511 | return "enabled slash_command." |
| 1512 | } |
| 1513 | installSourceAdded := false |
| 1514 | addInstallSourceTool := func() string { |
| 1515 | if installSourceAdded { |
| 1516 | return "install_source is already enabled." |
| 1517 | } |
| 1518 | installSourceAdded = true |
| 1519 | reg.Add(installsource.NewTool(installsource.Options{ |
| 1520 | ProjectRoot: root, |
| 1521 | HTTPClient: balanceClient, |
| 1522 | ConnectMCP: func(e config.PluginEntry) (installsource.MCPConnectResult, error) { |
| 1523 | spec := pluginSpecFromEntryWithOptions(e, root, pluginSpecOptions) |
| 1524 | if opts.Stderr != nil { |
| 1525 | spec.Stderr = opts.Stderr |
| 1526 | } |
| 1527 | // Applying an install plan is already an explicit user decision. |
| 1528 | // Project-scoped installs retain project provenance, but record the |
| 1529 | // exact durable launch grant now so neither this connection nor the |
| 1530 | // next session asks the user to authorize the same install again. |
| 1531 | launchAuthorized := false |
| 1532 | if spec.RequireLaunchApproval { |
| 1533 | if err := plugin.AuthorizeSpecLaunch(ctx, spec); err != nil { |
| 1534 | return installsource.MCPConnectResult{}, err |
| 1535 | } |
| 1536 | launchAuthorized = true |
| 1537 | } |
| 1538 | tools, err := pluginHost.Add(ctx, spec) |
| 1539 | if err != nil { |
| 1540 | // The install did not complete, so do not retain consent for a |
| 1541 | // server that never connected. Replacement rollback reauthorizes |
| 1542 | // the previous project entry before reconnecting it. |
| 1543 | if launchAuthorized && spec.LaunchManager != nil { |
| 1544 | _ = spec.LaunchManager.Revoke(spec.Name) |
| 1545 | } |
| 1546 | return installsource.MCPConnectResult{}, err |
| 1547 | } |
| 1548 | reg.RemovePrefix(plugin.ToolPrefix(spec.Name)) |
| 1549 | for _, t := range tools { |
| 1550 | reg.Add(t) |
| 1551 | } |
| 1552 | // Disconnect closes the server and drops its namespaced tools. |
| 1553 | // Used by the install_source rollback path when SaveTo fails. |
| 1554 | disconnect := func() { |
| 1555 | if prefix, ok := pluginHost.Remove(spec.Name); ok { |
| 1556 | reg.RemovePrefix(prefix) |
| 1557 | } |
| 1558 | if spec.LaunchManager != nil { |
| 1559 | _ = spec.LaunchManager.Revoke(spec.Name) |
| 1560 | } |
| 1561 | } |
| 1562 | return installsource.MCPConnectResult{ |
| 1563 | ToolCount: len(tools), |
| 1564 | Disconnect: disconnect, |
| 1565 | }, nil |
| 1566 | }, |
| 1567 | OnDisconnect: func(serverName string) bool { |
| 1568 | if prefix, ok := pluginHost.Remove(serverName); ok { |
| 1569 | reg.RemovePrefix(prefix) |
| 1570 | return true |
| 1571 | } |
| 1572 | return false |
| 1573 | }, |
| 1574 | })) |
| 1575 | return "enabled install_source." |
| 1576 | } |
| 1577 | readOnlySkillToolsAdded := false |
| 1578 | addReadOnlySkillTools := func() string { |
| 1579 | if !implicitSkillInvocation { |
| 1580 | return "automatic skill invocation is disabled; use an explicit /skill command instead." |
| 1581 | } |
| 1582 | if readOnlySkillToolsAdded { |
| 1583 | return "read_only_skill tool is already enabled.\n\n" + skill.ReadOnlyIndexBlock(skills) |
| 1584 | } |
| 1585 | readOnlySkillToolsAdded = true |
| 1586 | reg.Add(skill.NewReadOnlySkillTool(skillStore, gateSubagentArm(opts.Ablation, readOnlySkillRunner), skillProfile)) |
| 1587 | 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) |
| 1588 | } |
| 1589 | skillToolsAdded := false |
| 1590 | addSkillTools := func() string { |
| 1591 | if !implicitSkillInvocation { |
| 1592 | return "automatic skill invocation is disabled; use an explicit /skill command instead." |
| 1593 | } |
| 1594 | if skillToolsAdded { |
| 1595 | return "skills are already enabled.\n\n" + skill.IndexBlock(skills) |
| 1596 | } |
| 1597 | skillToolsAdded = true |
| 1598 | addReadOnlySkillTools() |
| 1599 | reg.Add(skill.NewRunSkillTool(skillStore, gateSubagentArm(opts.Ablation, skillRunner), skillProfile)) |
| 1600 | reg.Add(skill.NewReadSkillTool(skillStore)) |
| 1601 | reg.Add(skill.NewInstallSkillTool(skillStore, nil)) |
| 1602 | for _, t := range builtinSubagentTools(opts.Ablation, skillStore, skillRunner, skillProfile) { |
| 1603 | reg.Add(t) |
| 1604 | } |
| 1605 | addSlashCommandTool(implicitSkillInvocation) |
| 1606 | 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) |
| 1607 | } |
| 1608 | addInstallSourceTool() |
| 1609 | if implicitSkillInvocation { |
| 1610 | addSkillTools() |
| 1611 | } else { |
| 1612 | addSlashCommandTool(false) |
| 1613 | } |
| 1614 | |
| 1615 | // Session-shared MCP runtime: Host, specs, and connection snapshots. Each |
| 1616 | // agent gets its own use_capability frontend (ledger/audit isolation) while |
| 1617 | // reusing processes. Delivery puts a frontend on the executor registry; |
| 1618 | // dual-model Planner and all task/fleet sub-agents get their own frontends |
| 1619 | // without inheriting dynamic mcp__* schemas. |
| 1620 | var capLedger *capability.Ledger |
| 1621 | var capAudit *capability.Audit |
| 1622 | capEntries, capSpecs := capabilityServerInventory(cfg.Plugins, root, pluginSpecOptions, extraSpecs, enabledMCPNames) |
| 1623 | cachedTools, cacheKeyOK := capability.LoadCachedToolsForSpecs(capSpecs, pluginHost.Profile()) |
| 1624 | skillStore.ConfigureToolBindings(func(sk skill.Skill) []tool.MCPBinding { |
| 1625 | return skillMCPBindings(sk, reg, capSpecs, cachedTools, cacheKeyOK) |
| 1626 | }) |
| 1627 | var capProxy *agent.UseCapabilityTool |
| 1628 | // Catalog closes over capRuntime so proxy-connected tools stay routable. |
| 1629 | // Include non-provider-visible tools that use_capability can dispatch while |
| 1630 | // omitting replay-only compatibility aliases from discovery. |
| 1631 | catalogFn := func() capability.Catalog { |
| 1632 | conn := map[string]bool{} |
| 1633 | failedNow := map[string]string{} |
| 1634 | if pluginHost != nil { |
| 1635 | for _, n := range pluginHost.ServerNames() { |
| 1636 | conn[n] = true |
| 1637 | } |
| 1638 | for _, failure := range pluginHost.Failures() { |
| 1639 | failedNow[failure.Name] = failure.Error |
| 1640 | } |
| 1641 | } |
| 1642 | skillSnapshot, skillSnapshotErr := skillStore.Snapshot(ctx) |
| 1643 | catOpts := capability.CatalogOptions{ |
| 1644 | Tools: reg.CapabilityContractEntries(), |
| 1645 | Skills: skillSnapshot.Candidates, |
| 1646 | Plugins: cfg.Plugins, |
| 1647 | Connected: conn, |
| 1648 | Failed: failedNow, |
| 1649 | CachedTools: cachedTools, |
| 1650 | CacheKeyOK: cacheKeyOK, |
| 1651 | CatalogIncomplete: skillSnapshotErr != nil || !skillSnapshot.Complete, |
| 1652 | CatalogStale: skillSnapshot.Stale, |
| 1653 | } |
| 1654 | if capRuntime != nil { |
| 1655 | catOpts.Plugins, catOpts.CachedTools, catOpts.CacheKeyOK, catOpts.Disabled, catOpts.ProxyTools = capRuntime.CapabilityCatalogState() |
| 1656 | } |
| 1657 | return capability.BuildCatalog(catOpts) |
| 1658 | } |
| 1659 | // Always build the capability runtime and provider-visible use_capability |
| 1660 | // proxy so all three role settings share one tool schema. |
| 1661 | capRuntime = agent.NewMCPCapabilityRuntime(ctx, pluginHost, capSpecs, reg, catalogFn) |
| 1662 | capRuntime.ConfigureServers(capEntries, capSpecs, enabledMCPNames) |
| 1663 | capLedger = capability.NewLedger() |
| 1664 | capAudit = &capability.Audit{} |
| 1665 | capProxy = capRuntime.NewFrontend(capLedger, capAudit) |
| 1666 | reg.Add(capProxy) |
| 1667 | skillStore.ConfigureInvocationPolicy("", func(requires []string) []string { |
| 1668 | connected := map[string]bool{} |
| 1669 | failedNow := map[string]string{} |
| 1670 | if pluginHost != nil { |
| 1671 | for _, name := range pluginHost.ServerNames() { |
| 1672 | connected[name] = true |
| 1673 | } |
| 1674 | for _, failure := range pluginHost.Failures() { |
| 1675 | failedNow[failure.Name] = failure.Error |
| 1676 | } |
| 1677 | } |
| 1678 | catOpts := capability.CatalogOptions{ |
| 1679 | Tools: reg.CapabilityContractEntries(), |
| 1680 | Skills: skillStore.List(), |
| 1681 | Plugins: cfg.Plugins, |
| 1682 | Connected: connected, |
| 1683 | Failed: failedNow, |
| 1684 | CachedTools: cachedTools, |
| 1685 | CacheKeyOK: cacheKeyOK, |
| 1686 | } |
| 1687 | if capRuntime != nil { |
| 1688 | catOpts.Plugins, catOpts.CachedTools, catOpts.CacheKeyOK, catOpts.Disabled, catOpts.ProxyTools = capRuntime.CapabilityCatalogState() |
| 1689 | } |
| 1690 | catalog := capability.BuildCatalog(catOpts) |
| 1691 | _, missing := catalog.RequiresReady(requires) |
| 1692 | return missing |
| 1693 | }) |
| 1694 | |
| 1695 | execSess := newObservedSession(sysPrompt) |
| 1696 | executor := agent.New(execProv, reg, execSess, agent.Options{ |
| 1697 | ImageInput: imageConfig, |
| 1698 | MaxSteps: maxSteps, |
| 1699 | MaxStepsKey: opts.MaxStepsKey, |
| 1700 | Temperature: cfg.Agent.Temperature, |
| 1701 | TaskBudget: taskBudgetFromConfig(cfg), |
| 1702 | Pricing: entry.Price, |
| 1703 | QuoteContext: quoteCtx, |
| 1704 | ModelRef: modelRef, |
| 1705 | Gate: headlessGate, |
| 1706 | Hooks: hookRunner, |
| 1707 | Jobs: jm, |
| 1708 | // Parent write reservation at the executor entry covers all writers |
| 1709 | // (including late Economy/MCP adds) without wrapping tool schemas. |
| 1710 | WriteScheduler: subagentScheduler, |
| 1711 | WriteWorkspaceRoot: root, |
| 1712 | SessionTemp: sessionTemp, |
| 1713 | WriteRoots: writeRootSet, |
| 1714 | HomeDir: userHomeDir(), |
| 1715 | StateRoot: config.MemoryUserDir(), |
| 1716 | Ablation: opts.Ablation, |
| 1717 | WorkspaceLease: workspaceLease, |
| 1718 | CapabilityLedger: capLedger, |
| 1719 | CapabilityAudit: capAudit, |
| 1720 | ContextWindow: entry.ContextWindow, |
| 1721 | MaxOutputTokens: entry.MaxOutputTokens, |
| 1722 | SoftCompactRatio: cfg.Agent.SoftCompactRatio, |
| 1723 | ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio, |
| 1724 | CompactRatio: cfg.Agent.CompactRatio, |
| 1725 | CompactForceRatio: cfg.Agent.CompactForceRatio, |
| 1726 | ContextEditing: cfg.Agent.ContextEditing, |
| 1727 | RecentKeep: cfg.Agent.RecentKeep, |
| 1728 | ArchiveDir: config.ArchiveDir(), |
| 1729 | KeepPolicy: keepPolicy, |
| 1730 | ReasoningLanguage: config.ReasoningLanguageForEntry(entry, cfg.ReasoningLanguage()), |
| 1731 | PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands, |
| 1732 | SubagentDepth: 0, |
| 1733 | MaxSubagentDepth: maxSubagentDepth, |
| 1734 | MissingReasoningWarnStateDir: config.MissingReasoningWarnStateDir(), |
| 1735 | }, sink) |
| 1736 | reg.Add(sessiontool.NewSetSessionTitleEventTool( |
| 1737 | func() string { |
| 1738 | if controller := ctrlRef.Load(); controller != nil { |
| 1739 | if ref, ok := controller.SessionRef(); ok { |
| 1740 | return ref.SessionID |
| 1741 | } |
| 1742 | } |
| 1743 | return "" |
| 1744 | }, |
| 1745 | func(ctx context.Context, title string) error { |
| 1746 | controller := ctrlRef.Load() |
| 1747 | if controller == nil { |
| 1748 | return errors.New("current session is unavailable") |
| 1749 | } |
| 1750 | if err := controller.SetSessionTitle(ctx, title); err != nil { |
| 1751 | return err |
| 1752 | } |
| 1753 | if opts.OnSessionTitleChanged != nil { |
| 1754 | ref, _ := controller.SessionRef() |
| 1755 | return opts.OnSessionTitleChanged(sessionDir, ref.SessionID, title) |
| 1756 | } |
| 1757 | return nil |
| 1758 | }, |
| 1759 | )) |
| 1760 | |
| 1761 | var runner agent.Runner = executor |
| 1762 | label := entry.Model |
| 1763 | // Two-model collaboration: a distinct planner_model wraps the executor in a |
| 1764 | // Coordinator with its own session, kept separate for cache stability. The |
| 1765 | // planner gets the same standing memory context and a filtered read-only |
| 1766 | // research tool set, so it can inspect rules/code without side effects. |
| 1767 | pm := effectivePlannerModel(cfg, opts) |
| 1768 | pe, plannerResolved := resolveOptionalEntry(effectiveResolver, cfg, pm) |
| 1769 | if pm != "" && !plannerResolved { |
| 1770 | return nil, fmt.Errorf("planner_model %q is not a configured provider", pm) |
| 1771 | } |
| 1772 | if pm != "" && plannerResolved { |
| 1773 | plannerProv, err := resolveProvider(effectiveResolver, cfg, proxySpec, provider.Selection{Ref: modelRefFromEntry(pe)}) |
| 1774 | if err != nil { |
| 1775 | return nil, fmt.Errorf("planner_model %q: %w", pm, err) |
| 1776 | } |
| 1777 | plannerContext := mem.SystemBlock() |
| 1778 | if implicitSkillInvocation { |
| 1779 | plannerContext = strings.TrimSpace(plannerContext + "\n\n" + skill.ReadOnlyInvocationPolicyBlock()) |
| 1780 | } |
| 1781 | plannerSess := agent.NewSession(agent.PlannerPromptWithContext(plannerContext)) |
| 1782 | // Planner owns an independent ledger/audit and use_capability frontend |
| 1783 | // so its MCP calls cannot satisfy or poison Executor Delivery gates. |
| 1784 | plannerLedger := capability.NewLedger() |
| 1785 | plannerAudit := &capability.Audit{} |
| 1786 | plannerTools := agent.PlannerToolRegistry(reg) |
| 1787 | if capRuntime != nil { |
| 1788 | // Replace any cloned parent frontend with one bound to the |
| 1789 | // planner ledger (PlannerToolRegistry clones with nil ledger). |
| 1790 | if _, ok := plannerTools.Get("use_capability"); ok { |
| 1791 | plannerTools.RemovePrefix("use_capability") |
| 1792 | } |
| 1793 | plannerTools.Add(capRuntime.NewFrontend(plannerLedger, plannerAudit)) |
| 1794 | } |
| 1795 | plannerOpts := agent.Options{ |
| 1796 | ImageInput: imageConfig, |
| 1797 | MaxSteps: 0, |
| 1798 | Gate: headlessGate, |
| 1799 | ModelRef: modelRefFromEntry(pe), |
| 1800 | QuoteContext: quoteCtx, |
| 1801 | ContextWindow: pe.ContextWindow, |
| 1802 | SoftCompactRatio: cfg.Agent.SoftCompactRatio, |
| 1803 | ToolResultSnipRatio: cfg.Agent.ToolResultSnipRatio, |
| 1804 | CompactRatio: cfg.Agent.CompactRatio, |
| 1805 | CompactForceRatio: cfg.Agent.CompactForceRatio, |
| 1806 | ContextEditing: cfg.Agent.ContextEditing, |
| 1807 | RecentKeep: cfg.Agent.RecentKeep, |
| 1808 | ArchiveDir: config.ArchiveDir(), |
| 1809 | KeepPolicy: keepPolicy, |
| 1810 | ReasoningLanguage: config.ReasoningLanguageForEntry(pe, cfg.ReasoningLanguage()), |
| 1811 | PlanModeReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands, |
| 1812 | CapabilityLedger: plannerLedger, |
| 1813 | CapabilityAudit: plannerAudit, |
| 1814 | MissingReasoningWarnStateDir: config.MissingReasoningWarnStateDir(), |
| 1815 | WriteRoots: writeRootSet, |
| 1816 | HomeDir: userHomeDir(), |
| 1817 | StateRoot: config.MemoryUserDir(), |
| 1818 | } |
| 1819 | runner = agent.NewCoordinatorWithPlannerPolicy(plannerProv, plannerSess, pe.Price, plannerTools, plannerOpts, executor, cfg.Agent.Temperature, sink, control.NewPlannerPolicy()) |
| 1820 | label = entry.Model + " + planner " + pe.Model |
| 1821 | } |
| 1822 | imageEnabled := runtimeImageEnabled(execProv, modelCapabilities.Resolve(entry).State == config.CapabilitySupported) |
| 1823 | imageSnapshot := config.ModelCapabilitySnapshot(cfg, modelCapabilities) |
| 1824 | ctrlOpts := control.Options{ |
| 1825 | Authentication: authentication, |
| 1826 | AuthenticationForModel: authenticationReader(cfg, opts.ProviderResolver), |
| 1827 | ModelSettingsRevision: cfg.ModelRuntimeFingerprint(modelRef), |
| 1828 | ModelSettingsCurrent: runtimeModelSettingsReader(root, modelName, modelRef, opts.ModelSettings), |
| 1829 | FrozenImageInput: &imageEnabled, |
| 1830 | ImageCapabilityChanged: runtimeImageCapabilityReader(root, modelName, imageSnapshot, opts.ModelSettings), |
| 1831 | TaskBudget: taskBudgetFromConfig(cfg), |
| 1832 | GoalTokenBudget: cfg.Agent.GoalTokenBudget, |
| 1833 | Runner: runner, |
| 1834 | Executor: executor, |
| 1835 | Sink: sink, |
| 1836 | Policy: policy, |
| 1837 | SubagentGate: headlessGate, |
| 1838 | Label: label, |
| 1839 | ModelRef: modelRef, |
| 1840 | ModelIdentity: cfg.ModelSelectionIdentity(modelRef), |
| 1841 | ResolveSessionModel: cfg.ResolveSavedModel, |
| 1842 | VisionModel: cfg.Agent.VisionModel, |
| 1843 | VisionProviderResolver: visionProviderResolver, |
| 1844 | VisionModelSelector: visionModelSelector, |
| 1845 | ModelCapabilityResolver: modelCapabilities.Resolve, |
| 1846 | SystemPrompt: sysPrompt, |
| 1847 | PinnedContextLoader: opts.PinnedContextLoader, |
| 1848 | SessionDir: sessionDir, |
| 1849 | SessionService: sessionService, |
| 1850 | SessionRuntime: opts.SessionRuntime, |
| 1851 | ExclusiveSession: sessionService != nil, |
| 1852 | Host: pluginHost, |
| 1853 | Commands: cmds, |
| 1854 | Skills: skills, |
| 1855 | AllSkills: allSkills, |
| 1856 | SkillStore: skillStore, |
| 1857 | AllSkillStore: allSkillStore, |
| 1858 | DisableImplicitSkillInvocation: !implicitSkillInvocation, |
| 1859 | SkillRunner: skillRunner, |
| 1860 | ReadOnlySkillRunner: readOnlySkillRunner, |
| 1861 | SkillProfile: skillProfile, |
| 1862 | Hooks: hookRunner, |
| 1863 | Memory: mem, |
| 1864 | // Indirection: the cleanup variable gains the extension runtime set at |
| 1865 | // the end of build (snapshot assembly runs after control.New), and the |
| 1866 | // controller must observe the final chain at Close time. |
| 1867 | Cleanup: func() { cleanup() }, |
| 1868 | BalanceURL: entry.BalanceURL, |
| 1869 | BalanceKey: entry.APIKey(), |
| 1870 | BalanceClient: balanceClient, |
| 1871 | Jobs: jm, |
| 1872 | TaskStore: opts.TaskStore, |
| 1873 | WorkspaceLease: workspaceLease, |
| 1874 | Registry: reg, |
| 1875 | PluginCtx: ctx, |
| 1876 | MCPDefaultCallTimeout: pluginSpecOptions.DefaultCallTimeout, |
| 1877 | MCPConfigureSpec: func(spec *plugin.Spec) { |
| 1878 | if spec == nil { |
| 1879 | return |
| 1880 | } |
| 1881 | spec.LaunchManager = pluginSpecOptions.LaunchManager |
| 1882 | if strings.TrimSpace(spec.ConfigSource) == "" { |
| 1883 | spec.ConfigSource = pluginSpecOptions.ConfigSource |
| 1884 | } |
| 1885 | if spec.DefaultStartupTimeout <= 0 { |
| 1886 | spec.DefaultStartupTimeout = pluginSpecOptions.DefaultStartupTimeout |
| 1887 | } |
| 1888 | applyMCPIsolation(spec, root, pluginSpecOptions) |
| 1889 | }, |
| 1890 | CapabilityRuntime: capRuntime, |
| 1891 | WorkspaceRoot: root, |
| 1892 | ExternalFolderToolRefs: readPathResolver, |
| 1893 | ResponseLanguage: cfg.ResponseLanguage(), |
| 1894 | ReasoningLanguage: config.ReasoningLanguageForEntry(entry, cfg.ReasoningLanguage()), |
| 1895 | SessionContextStatic: sessionContextStatic, |
| 1896 | DisableColdResumePrune: !cfg.ColdResumePruneEnabled(), |
| 1897 | FileBranchesOnly: opts.FileBranchesOnly, |
| 1898 | Shell: shell, |
| 1899 | ApprovalTimeout: opts.ApprovalTimeout, |
| 1900 | Ablation: opts.Ablation, |
| 1901 | WriteRoots: writeRootSet, |
| 1902 | BashSandboxEnforced: bashSpec.Enforce() && sandbox.Available(), |
| 1903 | OnPersistWriteAccess: projectWriteAccessPersister(root), |
| 1904 | OnRemember: func(rule string) control.RememberResult { |
| 1905 | return rememberPermissionRule(root, rule) |
| 1906 | }, |
| 1907 | OnRememberPlanModeReadOnlyCommand: func(prefix string) control.PlanModeReadOnlyCommandTrustResult { |
| 1908 | return rememberPlanModeReadOnlyCommand(root, prefix) |
| 1909 | }, |
| 1910 | SessionRecoveryMeta: opts.SessionRecoveryMeta, |
| 1911 | OnSessionRecovered: opts.OnSessionRecovered, |
| 1912 | OnSessionTransition: opts.OnSessionTransition, |
| 1913 | OnSessionRotation: opts.OnSessionRotation, |
| 1914 | BeforeInboxDispatch: opts.BeforeInboxDispatch, |
| 1915 | // The merged catalog lets frontends enumerate sidecar providers. |
| 1916 | ProviderResolver: extensionResolver, |
| 1917 | RuntimeGeneration: generation, |
| 1918 | RuntimeOwner: owner, |
| 1919 | // Share the Manager already bound into bash/grep so tools and the |
| 1920 | // Controller observe the same temporary generation across rebuilds. |
| 1921 | SessionTemp: sessionTemp, |
| 1922 | PersistentShell: persistentShell, |
| 1923 | } |
| 1924 | if opts.ModelSettings != nil { |
| 1925 | ctrlOpts.ModelSettingsSourceRevision = opts.ModelSettings.Revision |
| 1926 | } |
| 1927 | // Guardian: when guardian_model is configured, spawn an LLM safety reviewer |
| 1928 | // that can auto-allow safe Ask decisions and annotate risky ones before |
| 1929 | // escalating to the human approval prompt. |
| 1930 | if guardianModel := cfg.Agent.GuardianModel; guardianModel != "" { |
| 1931 | ge, ok := resolveOptionalEntry(effectiveResolver, cfg, guardianModel) |
| 1932 | if !ok { |
| 1933 | return nil, fmt.Errorf("guardian_model %q is not a configured provider", guardianModel) |
| 1934 | } |
| 1935 | pProv, err := resolveProvider(effectiveResolver, cfg, proxySpec, provider.Selection{Ref: modelRefFromEntry(ge)}) |
| 1936 | if err != nil { |
| 1937 | return nil, fmt.Errorf("guardian_model %q: %w", guardianModel, err) |
| 1938 | } |
| 1939 | guardianReg := agent.FilterReadOnlyRegistry(reg, agent.SubagentMetaTools()...) |
| 1940 | ctrlOpts.Guardian = guardian.NewSession(pProv, guardianReg, guardian.PolicyPrompt(), modelRefFromEntry(ge), cfg.Agent.GuardianTemperature, ge.Price, sink) |
| 1941 | sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("guardian enabled · model=%s", ge.Model)}) |
| 1942 | } |
| 1943 | // Goal evaluator is not implied by the main model, guardian, or recovery |
| 1944 | // reviewer. Controllers that want one inject it explicitly; otherwise Goal |
| 1945 | // uses the deterministic host policy. |
| 1946 | ctrl := newControllerWithImageRoutes(ctrlOpts, cfg) |
| 1947 | // Validate and consume retired role inputs without changing runtime policy. |
| 1948 | _, _ = agentpreset.Normalize(firstNonEmpty(opts.AgentPreset, opts.TokenMode)) |
| 1949 | // Publish the controller to the extension UI hub's indirection: from here |
| 1950 | // on, host/ui/* publishes ride ctrl.EmitExtensionEvent and blocking prompts |
| 1951 | // ride ctrl.Ask, exactly as if the hub had been built after control.New. |
| 1952 | ctrlRef.Store(ctrl) |
| 1953 | close(controllerReady) |
| 1954 | if capRuntime != nil { |
| 1955 | ctrl.SetCapabilityProxyTools(capRuntime.ConnectedProxyTools) |
| 1956 | } |
| 1957 | // Task tools created before capRuntime assignment still need the runtime if |
| 1958 | // they were built early; re-bind when present. |
| 1959 | if taskTool != nil && capRuntime != nil { |
| 1960 | taskTool.WithCapabilityRuntime(capRuntime) |
| 1961 | } |
| 1962 | // Build one role-neutral semantic router so an in-place switch never needs a |
| 1963 | // controller rebuild. Host constraints and live capability routing decide whether a turn may call |
| 1964 | // it; construction alone does not add a provider request. |
| 1965 | var router *capability.SemanticRouter |
| 1966 | if modelRef := strings.TrimSpace(cfg.Agent.SubagentModels["capability-router"]); modelRef != "" { |
| 1967 | effortRef := strings.TrimSpace(cfg.Agent.SubagentEfforts["capability-router"]) |
| 1968 | if p, price, _, err := resolveSubagentProvider(modelRef, effortRef); err == nil && p != nil { |
| 1969 | usageModelRef, _ := subagentIdentity(modelRef, effortRef) |
| 1970 | router = &capability.SemanticRouter{Provider: p, Sink: sink, Model: usageModelRef, Pricing: price, QuoteContext: quoteCtx, Audit: capAudit} |
| 1971 | } |
| 1972 | } |
| 1973 | if router == nil { |
| 1974 | router = &capability.SemanticRouter{Provider: execProv, Sink: sink, Model: modelRef, Pricing: entry.Price, QuoteContext: quoteCtx, Audit: capAudit} |
| 1975 | } |
| 1976 | ctrl.WireCapabilityRouting(cfg.Plugins, capSpecs, router, capAudit) |
| 1977 | ctrl.SetCapabilityProxyRouting(true) |
| 1978 | |
| 1979 | // Provider-visible tool surface is identical for every role setting before |
| 1980 | // the extension snapshot freezes registry schemas for cache diagnostics. |
| 1981 | applyUnifiedProviderToolSurface(reg) |
| 1982 | |
| 1983 | // Freeze the extension kernel's snapshot of exactly what this build wired. |
| 1984 | // The snapshot is assembled from the in-hand objects above — discovery |
| 1985 | // never re-runs — and assembly must never fail the boot: a kernel error |
| 1986 | // degrades to a nil snapshot (logged) while the controller behaves exactly |
| 1987 | // as before. The sidecar Manager comes from preflight (started once, |
| 1988 | // before model resolution); assembly takes over its ownership and freezes |
| 1989 | // the same generation the sidecars were handshaken with. The frozen |
| 1990 | // provider catalog is the BASE catalog, exactly as before the preflight |
| 1991 | // refactor: sidecar providers enter the snapshot through the Manager's own |
| 1992 | // contributions, not through the legacy provider list. |
| 1993 | mcpSpecs := enabledMCPSpecs(configSpecs, extraSpecs) |
| 1994 | snap, runtimeSet, extensionDispatcher, snapErr := assembleLegacySnapshot(ctx, legacyAssembly{ |
| 1995 | systemPrompt: sysPrompt, |
| 1996 | registry: reg, |
| 1997 | skills: skills, |
| 1998 | commands: cmds, |
| 1999 | hooks: resolvedHooks, |
| 2000 | mcpSpecs: mcpSpecs, |
| 2001 | providers: baseResolver.Catalog(), |
| 2002 | }, generation, extensionBoot{ |
| 2003 | session: protocol.SessionContext{SessionID: sessionID, WorkspaceRoot: root, Generation: generation}, |
| 2004 | ui: extUIHub, |
| 2005 | onWarning: extWarn, |
| 2006 | skipPromptStrategy: shouldSkipPromptStrategy(opts.PreviousPlan), |
| 2007 | previousDispatcher: opts.PreviousDispatcher, |
| 2008 | }, extensionMgr) |
| 2009 | // Ownership of the preflighted Manager transferred to assembly on every |
| 2010 | // path: it was either closed inside or registered into the RuntimeSet. |
| 2011 | pendingMgr = nil |
| 2012 | if snapErr != nil { |
| 2013 | // These assembly failures are fatal rather than degradable: two |
| 2014 | // runtimes claiming the same replacement slot (the kernel's |
| 2015 | // ReplaceClaims verdict) and a failed system_prompt.build strategy |
| 2016 | // ruling (the slot owner is required-class, so dispatch surfaces its |
| 2017 | // failure as one of these types) mean the extension contract the user |
| 2018 | // installed cannot be honored; booting without it would silently |
| 2019 | // change what the session is. (A required runtime that cannot start |
| 2020 | // fails earlier, in preflight, with the same fatality.) |
| 2021 | var requiredErr *sidecar.RequiredStartError |
| 2022 | var slotErr *extension.SlotConflictError |
| 2023 | var blockErr *dispatch.BlockError |
| 2024 | var failureErr *dispatch.FailureError |
| 2025 | var violationErr *dispatch.ViolationError |
| 2026 | if errors.As(snapErr, &requiredErr) || errors.As(snapErr, &slotErr) || |
| 2027 | errors.As(snapErr, &blockErr) || errors.As(snapErr, &failureErr) || errors.As(snapErr, &violationErr) { |
| 2028 | ctrl.ReleaseResources() |
| 2029 | return nil, fmt.Errorf("boot: %w", snapErr) |
| 2030 | } |
| 2031 | slog.Warn("boot: extension snapshot assembly failed; continuing without a runtime snapshot", "err", snapErr) |
| 2032 | runtimeSet = extension.NewRuntimeSet(generation) |
| 2033 | // Assembly retired the preflighted Manager on the error path; the |
| 2034 | // controller must not bind a hub or expose a manager whose sidecars |
| 2035 | // are already shut down. |
| 2036 | extensionMgr = nil |
| 2037 | } |
| 2038 | // The stage-7 provider merge happened at preflight, before model |
| 2039 | // resolution; BuildResult.ProviderResolver exposes that same merged |
| 2040 | // resolver (the base when no sidecar declared providers). |
| 2041 | providerResolver := baseResolver |
| 2042 | if extensionResolver != nil { |
| 2043 | providerResolver = extensionResolver |
| 2044 | } |
| 2045 | if runtimeSet != nil && runtimeSet.Len() > 0 { |
| 2046 | _ = extension.TrackWatcher(runtimeSet.Scope(), "skill-catalogs", func() error { skillCleanup(); return nil }) |
| 2047 | } |
| 2048 | cleanup = wireRuntimeScopeCleanup(runtimeSet, cleanup, opts.SharedHost, pluginHost, lspMgr, opts.SessionTemp, closeBrowser) |
| 2049 | ctrl.SetExtensions(extensionDispatcher) |
| 2050 | if extensionMgr == nil { |
| 2051 | extUIHub = nil |
| 2052 | } else { |
| 2053 | ctrl.SetExtensionUI(extUIHub) |
| 2054 | } |
| 2055 | if providerResolver != nil { |
| 2056 | ctrl.SetProviderResolver(providerResolver) |
| 2057 | } |
| 2058 | // Stage 6b2 system-prompt handoff: the 6b1 strategy pass may have replaced |
| 2059 | // the prompt while the snapshot was freezing, but the executor session was |
| 2060 | // built earlier with the host-composed prompt. Swap in a fresh session |
| 2061 | // carrying the final prompt now — before any turn or history resume, so |
| 2062 | // the live session and the frozen snapshot describe the same session. |
| 2063 | if snap != nil { |
| 2064 | if final := snap.SystemPrompt(); final != sysPrompt { |
| 2065 | ctrl.ApplyExtensionSystemPrompt(final) |
| 2066 | } |
| 2067 | } |
| 2068 | assembly := &ReusedAssembly{ |
| 2069 | SystemPrompt: sysPrompt, |
| 2070 | Skills: skills, |
| 2071 | Commands: cmds, |
| 2072 | Hooks: resolvedHooks, |
| 2073 | Registry: reg, |
| 2074 | ImplicitSkillInvocation: implicitSkillInvocation, |
| 2075 | } |
| 2076 | skillsOwned = true |
| 2077 | return finalizeBuildResult(&BuildResult{Controller: ctrl, Snapshot: snap, Runtime: runtimeSet, Owner: owner, Extensions: extensionMgr, Dispatcher: extensionDispatcher, ExtensionUI: extUIHub, ProviderResolver: providerResolver, BaseProviderResolver: baseResolver, Assembly: assembly, SkillWatchService: skillWatchService}, !opts.deferPublish), nil |
| 2078 | } |
| 2079 | |
| 2080 | // effectivePlannerModel centralizes planner precedence. Every role setting |
| 2081 | // builds the configured planner so later in-place switches retain the same |
| 2082 | // runtime; explicit Plan, approval, or Goal start decides whether it is invoked. |
| 2083 | func effectivePlannerModel(cfg *config.Config, opts Options) string { |
| 2084 | if cfg == nil || opts.Ablation.Off(ablation.Planner) { |
| 2085 | return "" |
| 2086 | } |
| 2087 | return strings.TrimSpace(cfg.Agent.PlannerModel) |
| 2088 | } |
| 2089 | |
| 2090 | func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult { |
| 2091 | path := rememberPermissionConfigPath(workspaceRoot) |
| 2092 | result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path} |
| 2093 | unlock, err := config.LockConfigFileEdits(path) |
| 2094 | if err != nil { |
| 2095 | slog.Warn("lock config for permission rule", "path", path, "err", err) |
| 2096 | result.Err = err |
| 2097 | return result |
| 2098 | } |
| 2099 | defer unlock() |
| 2100 | |
| 2101 | edit, err := config.LoadForEditReadOnlyStrict(path) |
| 2102 | if err != nil { |
| 2103 | slog.Warn("load config for permission rule", "path", path, "err", err) |
| 2104 | result.Err = err |
| 2105 | return result |
| 2106 | } |
| 2107 | if coveredBy := coveredPermissionRule(edit.Permissions.Allow, result.Rule); coveredBy != "" { |
| 2108 | result.CoveredBy = coveredBy |
| 2109 | return result |
| 2110 | } |
| 2111 | edit.Permissions.Allow = pruneCoveredPermissionRules(edit.Permissions.Allow, result.Rule) |
| 2112 | if err := edit.AddPermissionRule("allow", rule); err != nil { |
| 2113 | slog.Warn("persist permission rule", "rule", rule, "err", err) |
| 2114 | result.Err = err |
| 2115 | return result |
| 2116 | } |
| 2117 | if err := config.WritePermissionsAllow(path, edit.Permissions.Allow); err != nil { |
| 2118 | slog.Warn("save config after permission rule", "err", err) |
| 2119 | result.Err = err |
| 2120 | return result |
| 2121 | } |
| 2122 | result.Saved = true |
| 2123 | return result |
| 2124 | } |
| 2125 | |
| 2126 | func rememberPermissionConfigPath(workspaceRoot string) string { |
| 2127 | workspaceRoot = strings.TrimSpace(workspaceRoot) |
| 2128 | if workspaceRoot != "" { |
| 2129 | return filepath.Join(workspaceRoot, "reasonix.toml") |
| 2130 | } |
| 2131 | path := config.SourcePath() |
| 2132 | if path == "" { |
| 2133 | path = "reasonix.toml" // match Config.Save() fallback |
| 2134 | } |
| 2135 | return path |
| 2136 | } |
| 2137 | |
| 2138 | func rememberPlanModeReadOnlyCommand(workspaceRoot, prefix string) control.PlanModeReadOnlyCommandTrustResult { |
| 2139 | prefix = strings.TrimSpace(prefix) |
| 2140 | path := rememberPermissionConfigPath(workspaceRoot) |
| 2141 | result := control.PlanModeReadOnlyCommandTrustResult{Prefix: prefix, Path: path} |
| 2142 | if prefix == "" { |
| 2143 | result.Err = fmt.Errorf("empty plan-mode read-only command prefix") |
| 2144 | return result |
| 2145 | } |
| 2146 | unlock, err := config.LockConfigFileEdits(path) |
| 2147 | if err != nil { |
| 2148 | result.Err = err |
| 2149 | return result |
| 2150 | } |
| 2151 | defer unlock() |
| 2152 | edit, err := config.LoadForEditReadOnlyStrict(path) |
| 2153 | if err != nil { |
| 2154 | result.Err = err |
| 2155 | return result |
| 2156 | } |
| 2157 | if coveredBy := coveredPlanModeReadOnlyCommand(edit.Agent.PlanModeReadOnlyCommands, prefix); coveredBy != "" { |
| 2158 | result.CoveredBy = coveredBy |
| 2159 | return result |
| 2160 | } |
| 2161 | edit.Agent.PlanModeReadOnlyCommands = append(edit.Agent.PlanModeReadOnlyCommands, prefix) |
| 2162 | if err := edit.SaveTo(path); err != nil { |
| 2163 | slog.Warn("persist plan-mode read-only command trust", "prefix", prefix, "err", err) |
| 2164 | result.Err = err |
| 2165 | return result |
| 2166 | } |
| 2167 | result.Saved = true |
| 2168 | return result |
| 2169 | } |
| 2170 | |
| 2171 | func coveredPlanModeReadOnlyCommand(existing []string, candidate string) string { |
| 2172 | candidateFields := strings.Fields(strings.TrimSpace(candidate)) |
| 2173 | if len(candidateFields) == 0 { |
| 2174 | return "" |
| 2175 | } |
| 2176 | for _, item := range existing { |
| 2177 | itemFields := strings.Fields(strings.TrimSpace(item)) |
| 2178 | if len(itemFields) == 0 || len(itemFields) > len(candidateFields) { |
| 2179 | continue |
| 2180 | } |
| 2181 | matches := true |
| 2182 | for i, field := range itemFields { |
| 2183 | if candidateFields[i] != field { |
| 2184 | matches = false |
| 2185 | break |
| 2186 | } |
| 2187 | } |
| 2188 | if matches { |
| 2189 | return strings.Join(itemFields, " ") |
| 2190 | } |
| 2191 | } |
| 2192 | return "" |
| 2193 | } |
| 2194 | |
| 2195 | func coveredPermissionRule(rules []string, rule string) string { |
| 2196 | for _, existing := range rules { |
| 2197 | if permission.RuleCoversString(existing, rule) { |
| 2198 | return strings.TrimSpace(existing) |
| 2199 | } |
| 2200 | } |
| 2201 | return "" |
| 2202 | } |
| 2203 | |
| 2204 | func pruneCoveredPermissionRules(rules []string, rule string) []string { |
| 2205 | out := rules[:0] |
| 2206 | for _, existing := range rules { |
| 2207 | if strings.TrimSpace(existing) == "" || permission.RuleCoversString(rule, existing) { |
| 2208 | continue |
| 2209 | } |
| 2210 | out = append(out, existing) |
| 2211 | } |
| 2212 | return out |
| 2213 | } |
| 2214 | |
| 2215 | func firstNonEmpty(vals ...string) string { |
| 2216 | for _, v := range vals { |
| 2217 | if strings.TrimSpace(v) != "" { |
| 2218 | return strings.TrimSpace(v) |
| 2219 | } |
| 2220 | } |
| 2221 | return "" |
| 2222 | } |
| 2223 | |
| 2224 | func subagentModelRef(cfg *config.Config, sk skill.Skill) string { |
| 2225 | if cfg != nil { |
| 2226 | for _, key := range SubagentModelKeys(sk.Name) { |
| 2227 | if m := strings.TrimSpace(cfg.Agent.SubagentModels[key]); m != "" { |
| 2228 | return m |
| 2229 | } |
| 2230 | } |
| 2231 | } |
| 2232 | if m := strings.TrimSpace(sk.Model); m != "" { |
| 2233 | return m |
| 2234 | } |
| 2235 | if cfg == nil { |
| 2236 | return "" |
| 2237 | } |
| 2238 | return strings.TrimSpace(cfg.Agent.SubagentModel) |
| 2239 | } |
| 2240 | |
| 2241 | func subagentEffortRef(cfg *config.Config, sk skill.Skill) string { |
| 2242 | if cfg != nil { |
| 2243 | for _, key := range SubagentModelKeys(sk.Name) { |
| 2244 | if e := strings.TrimSpace(cfg.Agent.SubagentEfforts[key]); e != "" { |
| 2245 | return e |
| 2246 | } |
| 2247 | } |
| 2248 | } |
| 2249 | if e := strings.TrimSpace(sk.Effort); e != "" { |
| 2250 | return e |
| 2251 | } |
| 2252 | if cfg == nil { |
| 2253 | return "" |
| 2254 | } |
| 2255 | return strings.TrimSpace(cfg.Agent.SubagentEffort) |
| 2256 | } |
| 2257 | |
| 2258 | // SubagentModelKeys returns the cfg.Agent.SubagentModels/SubagentEfforts map |
| 2259 | // keys that resolve for a subagent name, in precedence order: the exact name |
| 2260 | // first, then its underscore/hyphen alias variants (the dedicated tool |
| 2261 | // security_review dispatches the skill security-review, so either spelling in |
| 2262 | // config must reach it). Any surface that reads OR clears these maps must |
| 2263 | // iterate this same key set — an exact-key delete leaves an alias entry |
| 2264 | // silently active. |
| 2265 | func SubagentModelKeys(name string) []string { |
| 2266 | name = strings.TrimSpace(name) |
| 2267 | if name == "" { |
| 2268 | return nil |
| 2269 | } |
| 2270 | keys := []string{name} |
| 2271 | for _, alias := range []string{ |
| 2272 | strings.ReplaceAll(name, "-", "_"), |
| 2273 | strings.ReplaceAll(name, "_", "-"), |
| 2274 | } { |
| 2275 | if alias == "" { |
| 2276 | continue |
| 2277 | } |
| 2278 | seen := slices.Contains(keys, alias) |
| 2279 | if !seen { |
| 2280 | keys = append(keys, alias) |
| 2281 | } |
| 2282 | } |
| 2283 | return keys |
| 2284 | } |
| 2285 | |
| 2286 | func currentWorkspacePromptLine(root string) string { |
| 2287 | if root == "" { |
| 2288 | return "" |
| 2289 | } |
| 2290 | return "Current workspace: " + strconv.Quote(root) |
| 2291 | } |
| 2292 | |
| 2293 | func resolveWorkspaceRoot(explicit string) string { |
| 2294 | if explicit != "" { |
| 2295 | return explicit |
| 2296 | } |
| 2297 | wd, err := os.Getwd() |
| 2298 | if err != nil { |
| 2299 | return "" |
| 2300 | } |
| 2301 | if root, ok := nearestGitRoot(wd); ok { |
| 2302 | return root |
| 2303 | } |
| 2304 | return wd |
| 2305 | } |
| 2306 | |
| 2307 | func normalizeAdditionalDirs(root string, dirs []string) ([]string, error) { |
| 2308 | if len(dirs) == 0 { |
| 2309 | return nil, nil |
| 2310 | } |
| 2311 | base := strings.TrimSpace(root) |
| 2312 | if base == "" { |
| 2313 | base = "." |
| 2314 | } |
| 2315 | if !filepath.IsAbs(base) { |
| 2316 | abs, err := filepath.Abs(base) |
| 2317 | if err != nil { |
| 2318 | return nil, fmt.Errorf("resolve workspace root: %w", err) |
| 2319 | } |
| 2320 | base = abs |
| 2321 | } |
| 2322 | |
| 2323 | var out []string |
| 2324 | for _, raw := range dirs { |
| 2325 | dir := strings.TrimSpace(raw) |
| 2326 | if dir == "" { |
| 2327 | continue |
| 2328 | } |
| 2329 | if !filepath.IsAbs(dir) { |
| 2330 | dir = filepath.Join(base, dir) |
| 2331 | } |
| 2332 | dir, err := filepath.Abs(filepath.Clean(dir)) |
| 2333 | if err != nil { |
| 2334 | return nil, fmt.Errorf("resolve additional directory %q: %w", raw, err) |
| 2335 | } |
| 2336 | real, err := filepath.EvalSymlinks(dir) |
| 2337 | if err != nil { |
| 2338 | return nil, fmt.Errorf("resolve additional directory %q: %w", raw, err) |
| 2339 | } |
| 2340 | info, err := os.Stat(real) |
| 2341 | if err != nil { |
| 2342 | return nil, fmt.Errorf("inspect additional directory %q: %w", raw, err) |
| 2343 | } |
| 2344 | if !info.IsDir() { |
| 2345 | return nil, fmt.Errorf("additional path %q is not a directory", raw) |
| 2346 | } |
| 2347 | out = appendUniquePaths(out, filepath.Clean(real)) |
| 2348 | } |
| 2349 | return out, nil |
| 2350 | } |
| 2351 | |
| 2352 | func appendUniquePaths(base []string, extra ...string) []string { |
| 2353 | out := append([]string(nil), base...) |
| 2354 | seen := make(map[string]struct{}, len(out)) |
| 2355 | for _, path := range out { |
| 2356 | seen[pathComparisonKey(path)] = struct{}{} |
| 2357 | } |
| 2358 | for _, path := range extra { |
| 2359 | path = filepath.Clean(path) |
| 2360 | key := pathComparisonKey(path) |
| 2361 | if _, ok := seen[key]; ok { |
| 2362 | continue |
| 2363 | } |
| 2364 | seen[key] = struct{}{} |
| 2365 | out = append(out, path) |
| 2366 | } |
| 2367 | return out |
| 2368 | } |
| 2369 | |
| 2370 | // RuntimeForbidReadRoots returns the configured deny roots plus Reasonix's |
| 2371 | // global credential file when the host can enforce that read boundary without |
| 2372 | // changing the caller's own ACL. It always registers the corresponding |
| 2373 | // credential environment names for subprocess filtering. Runtime tool |
| 2374 | // assemblers outside Build must use this helper instead of reading the config |
| 2375 | // roots directly. |
| 2376 | // |
| 2377 | // Provider and bot credentials are loaded into the parent process from this |
| 2378 | // file. macOS/Linux also hide the file from readers, shell commands, and MCP |
| 2379 | // servers when the optional broad sensitive-file denylist is off. Windows only |
| 2380 | // filters the values from child environments: WRITE_RESTRICTED does not confine |
| 2381 | // reads, and denying the caller SID would also lock out the host. Project .env |
| 2382 | // files retain their existing behavior. |
| 2383 | func RuntimeForbidReadRoots(cfg *config.Config, root string) []string { |
| 2384 | return runtimeForbidReadRootsForGOOS(cfg, root, runtime.GOOS) |
| 2385 | } |
| 2386 | |
| 2387 | func runtimeForbidReadRootsForGOOS(cfg *config.Config, root, goos string) []string { |
| 2388 | if cfg == nil { |
| 2389 | return nil |
| 2390 | } |
| 2391 | secrets.RegisterCredentialEnvKeys(cfg.CredentialEnvNames()) |
| 2392 | base := cfg.ForbidReadRootsForRoot(root) |
| 2393 | // WRITE_RESTRICTED constrains writes only. Keep filtering credential values |
| 2394 | // on Windows without denying the caller SID, which would also lock out the |
| 2395 | // host settings process and could survive a crash. |
| 2396 | if goos == "windows" { |
| 2397 | return append([]string(nil), base...) |
| 2398 | } |
| 2399 | credentialPath := strings.TrimSpace(config.UserCredentialsPath()) |
| 2400 | if credentialPath == "" { |
| 2401 | return append([]string(nil), base...) |
| 2402 | } |
| 2403 | info, err := os.Stat(credentialPath) |
| 2404 | if err != nil || info.IsDir() { |
| 2405 | return append([]string(nil), base...) |
| 2406 | } |
| 2407 | if real, err := filepath.EvalSymlinks(credentialPath); err == nil { |
| 2408 | credentialPath = real |
| 2409 | } |
| 2410 | return appendUniquePaths(base, credentialPath) |
| 2411 | } |
| 2412 | |
| 2413 | func pathComparisonKey(path string) string { |
| 2414 | path = filepath.Clean(path) |
| 2415 | if abs, err := filepath.Abs(path); err == nil { |
| 2416 | path = abs |
| 2417 | } |
| 2418 | if real, err := filepath.EvalSymlinks(path); err == nil { |
| 2419 | path = real |
| 2420 | } |
| 2421 | if runtime.GOOS == "windows" { |
| 2422 | return strings.ToLower(path) |
| 2423 | } |
| 2424 | return path |
| 2425 | } |
| 2426 | |
| 2427 | func nearestGitRoot(start string) (string, bool) { |
| 2428 | dir, err := filepath.Abs(start) |
| 2429 | if err != nil { |
| 2430 | dir = filepath.Clean(start) |
| 2431 | } |
| 2432 | for { |
| 2433 | if isGitMarker(filepath.Join(dir, ".git")) { |
| 2434 | return dir, true |
| 2435 | } |
| 2436 | next := filepath.Dir(dir) |
| 2437 | if next == dir { |
| 2438 | return "", false |
| 2439 | } |
| 2440 | dir = next |
| 2441 | } |
| 2442 | } |
| 2443 | |
| 2444 | func isGitMarker(path string) bool { |
| 2445 | fi, err := os.Stat(path) |
| 2446 | return err == nil && (fi.IsDir() || fi.Mode().IsRegular()) |
| 2447 | } |
| 2448 | |
| 2449 | func newSubagentStore(sessionDir string, parentLive func(sessionPath string) bool) (*agent.SubagentStore, error) { |
| 2450 | sessionDir = strings.TrimSpace(sessionDir) |
| 2451 | if sessionDir == "" { |
| 2452 | return nil, nil |
| 2453 | } |
| 2454 | store := agent.NewSubagentStore(filepath.Join(sessionDir, "subagents")).WithParentSessionProbe(parentLive) |
| 2455 | if _, err := store.CleanupStaleRunning(); err != nil { |
| 2456 | return nil, fmt.Errorf("cleanup stale subagents: %w", err) |
| 2457 | } |
| 2458 | return store, nil |
| 2459 | } |
| 2460 | |
| 2461 | func subagentEffectiveIdentity(cfg *config.Config, resolver provider.Resolver, baseModelRef string, base *config.ProviderEntry, modelRef, effort string) (string, string) { |
| 2462 | var entry config.ProviderEntry |
| 2463 | if base != nil { |
| 2464 | entry = *base |
| 2465 | } |
| 2466 | ref := strings.TrimSpace(modelRef) |
| 2467 | explicit := ref != "" |
| 2468 | if !explicit { |
| 2469 | ref = strings.TrimSpace(baseModelRef) |
| 2470 | } |
| 2471 | if explicit && cfg != nil && ref != "" { |
| 2472 | if resolved, ok := cfg.ResolveModel(ref); ok { |
| 2473 | entry = *resolved |
| 2474 | } else if resolved := syntheticEntryFromResolver(resolver, ref); strings.TrimSpace(resolved.Name) != "" { |
| 2475 | entry = *resolved |
| 2476 | } else { |
| 2477 | entry.Model = ref |
| 2478 | } |
| 2479 | } else if explicit { |
| 2480 | if resolved := syntheticEntryFromResolver(resolver, ref); strings.TrimSpace(resolved.Name) != "" { |
| 2481 | entry = *resolved |
| 2482 | } else { |
| 2483 | entry.Model = ref |
| 2484 | } |
| 2485 | } else if base == nil && ref != "" { |
| 2486 | if resolved := syntheticEntryFromResolver(resolver, ref); strings.TrimSpace(resolved.Name) != "" { |
| 2487 | entry = *resolved |
| 2488 | } else if cfg != nil { |
| 2489 | if resolved, ok := cfg.ResolveModel(ref); ok { |
| 2490 | entry = *resolved |
| 2491 | } |
| 2492 | } |
| 2493 | } |
| 2494 | if rawEffort := strings.TrimSpace(effort); rawEffort != "" { |
| 2495 | if normalized, err := config.NormalizeEffort(&entry, rawEffort); err == nil { |
| 2496 | entry.Effort = normalized |
| 2497 | } else { |
| 2498 | entry.Effort = rawEffort |
| 2499 | } |
| 2500 | } |
| 2501 | modelID := strings.TrimSpace(entry.Name) |
| 2502 | model := strings.TrimSpace(entry.Model) |
| 2503 | if modelID != "" && model != "" { |
| 2504 | modelID += "/" + model |
| 2505 | } else if model != "" { |
| 2506 | modelID = model |
| 2507 | } else if modelID == "" { |
| 2508 | modelID = ref |
| 2509 | } |
| 2510 | return modelID, strings.TrimSpace(config.EffectiveEffort(&entry)) |
| 2511 | } |
| 2512 | |
| 2513 | // addBuiltins adds enabled built-in tools to reg. An empty list means all of |
| 2514 | // them. writeRoots confines the file-writing built-ins to the workspace: after |
| 2515 | // the (unconfined) defaults are added, each enabled writer is replaced by an |
| 2516 | // instance bound to writeRoots (preserving registry order). |
| 2517 | // forbidReadRoots confines the read/list/search built-ins so they cannot peek at |
| 2518 | // the listed directories. |
| 2519 | // When workDir is non-empty, tools resolve relative paths against it instead of |
| 2520 | // the process cwd, enabling concurrent multi-project sessions. |
| 2521 | // sessionGuard blocks writer-tool targets inside Reasonix's own session stores |
| 2522 | // and makes bash warn when a command references them. managedConfig names the |
| 2523 | // Reasonix-owned config files writable outside writeRoots after a fresh |
| 2524 | // per-write human approval. |
| 2525 | func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, writeRootSet *sandbox.WritableRootSet, 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, sessionTemp *sessiontemp.Manager, fileWriteReceipt func(path string, hadPrior bool, prior []byte)) { |
| 2526 | // If a workspace directory is set, use workspace-bound tools that resolve |
| 2527 | // paths relative to that directory. Otherwise fall back to the process-cwd |
| 2528 | // compile-time builtins. |
| 2529 | if workDir != "" { |
| 2530 | ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, WriteRootSet: writeRootSet, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard, ManagedConfig: managedConfig, FileOverlay: overlay, Terminal: terminal, SessionTemp: sessionTemp, FileWriteReceipt: fileWriteReceipt} |
| 2531 | for _, t := range ws.Tools(enabled...) { |
| 2532 | reg.Add(t) |
| 2533 | } |
| 2534 | return |
| 2535 | } |
| 2536 | |
| 2537 | if len(enabled) == 0 { |
| 2538 | for _, t := range tool.Builtins() { |
| 2539 | reg.Add(t) |
| 2540 | } |
| 2541 | } else { |
| 2542 | for _, name := range enabled { |
| 2543 | name = canonicalBuiltinName(name) |
| 2544 | if t, ok := tool.LookupBuiltin(name); ok { |
| 2545 | reg.Add(t) |
| 2546 | } else { |
| 2547 | fmt.Fprintf(stderr, "warning: unknown built-in tool %q\n", name) |
| 2548 | } |
| 2549 | } |
| 2550 | } |
| 2551 | // Replace unconfined defaults with confined instances, preserving registry order: file-writers bound to the workspace, read tools |
| 2552 | // bound to forbid-read roots, bash to the OS sandbox, web_fetch to the proxy. |
| 2553 | // Only replace tools actually enabled/present. |
| 2554 | bashTool := builtin.ConfineBash(bashSpec, sessionGuard, bashTimeout) |
| 2555 | if rebound, ok := builtin.BindSessionTemp(bashTool, sessionTemp); ok { |
| 2556 | bashTool = rebound |
| 2557 | } |
| 2558 | searchTool := builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots) |
| 2559 | if rebound, ok := builtin.BindSessionTemp(searchTool, sessionTemp); ok { |
| 2560 | searchTool = rebound |
| 2561 | } |
| 2562 | writers := builtin.ConfineWriters(writeRoots, sessionGuard, managedConfig) |
| 2563 | for i, writer := range writers { |
| 2564 | writers[i] = builtin.BindFileWriteReceipt(writer, fileWriteReceipt) |
| 2565 | } |
| 2566 | confined := append(writers, |
| 2567 | searchTool, |
| 2568 | builtin.ConfineWebFetch(proxySpec)) |
| 2569 | confined = append(confined, builtin.ConfineReaders(forbidReadRoots)...) |
| 2570 | for i, tl := range confined { |
| 2571 | confined[i] = builtin.BindWriteRootSet(tl, writeRootSet) |
| 2572 | } |
| 2573 | for _, t := range confined { |
| 2574 | if _, ok := reg.Get(t.Name()); ok { |
| 2575 | reg.Add(t) |
| 2576 | } |
| 2577 | } |
| 2578 | registerShellBuiltin(reg, bashTool, writeRootSet) |
| 2579 | } |
| 2580 | |
| 2581 | // partitionByTier splits configured plugin entries into eager (block boot until |
| 2582 | // ready) and background (placeholder + start spawn now). Entries with an empty, |
| 2583 | // legacy lazy, or unrecognised tier land in background. |
| 2584 | func partitionByTier(entries []config.PluginEntry) (eager, bg []config.PluginEntry) { |
| 2585 | for _, e := range entries { |
| 2586 | switch e.ResolvedTier() { |
| 2587 | case "eager": |
| 2588 | eager = append(eager, e) |
| 2589 | default: |
| 2590 | bg = append(bg, e) |
| 2591 | } |
| 2592 | } |
| 2593 | return eager, bg |
| 2594 | } |
| 2595 | |
| 2596 | // PluginSpecs maps configured plugin entries to plugin.Spec, expanding ${VAR} |
| 2597 | // references. Exported so custom assemblers can connect the config's plugins |
| 2598 | // alongside their own (e.g. ACP's per-session MCP servers). |
| 2599 | func PluginSpecs(entries []config.PluginEntry) []plugin.Spec { |
| 2600 | return PluginSpecsForRoot(entries, "") |
| 2601 | } |
| 2602 | |
| 2603 | // PluginSpecsForRoot maps configured plugin entries to plugin.Spec and applies |
| 2604 | // workspace-aware compatibility overrides for known cwd-sensitive servers. |
| 2605 | func PluginSpecsForRoot(entries []config.PluginEntry, workspaceRoot string) []plugin.Spec { |
| 2606 | return PluginSpecsForRootWithOptions(entries, workspaceRoot, PluginSpecOptions{}) |
| 2607 | } |
| 2608 | |
| 2609 | // PluginSpecsForRootWithOptions maps configured plugin entries to plugin.Spec |
| 2610 | // and injects runtime policy such as the global MCP call timeout. |
| 2611 | func PluginSpecsForRootWithOptions(entries []config.PluginEntry, workspaceRoot string, opts PluginSpecOptions) []plugin.Spec { |
| 2612 | specs := make([]plugin.Spec, len(entries)) |
| 2613 | for i, e := range entries { |
| 2614 | specs[i] = pluginSpecFromEntryWithOptions(e, workspaceRoot, opts) |
| 2615 | } |
| 2616 | return specs |
| 2617 | } |
| 2618 | |
| 2619 | func pluginSpecFromEntryWithOptions(e config.PluginEntry, workspaceRoot string, opts PluginSpecOptions) plugin.Spec { |
| 2620 | e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment |
| 2621 | configSource := strings.TrimSpace(string(e.Source)) |
| 2622 | if configSource == "" { |
| 2623 | configSource = opts.ConfigSource |
| 2624 | } |
| 2625 | spec := plugin.ApplyKnownOverrides(plugin.Spec{ |
| 2626 | Name: e.Name, |
| 2627 | Package: strings.TrimSpace(opts.PackageOwners[e.Name]), |
| 2628 | Type: e.Type, |
| 2629 | Command: e.Command, |
| 2630 | Args: e.Args, |
| 2631 | Env: e.Env, |
| 2632 | URL: e.URL, |
| 2633 | Headers: e.Headers, |
| 2634 | DefaultStartupTimeout: opts.DefaultStartupTimeout, |
| 2635 | StartupTimeout: secondsDuration(e.StartupTimeoutSeconds), |
| 2636 | DefaultCallTimeout: opts.DefaultCallTimeout, |
| 2637 | CallTimeout: secondsDuration(e.CallTimeoutSeconds), |
| 2638 | ToolTimeouts: toolTimeoutDurations(e.ToolTimeoutSeconds), |
| 2639 | WorkspaceRoot: strings.TrimSpace(workspaceRoot), |
| 2640 | LaunchManager: opts.LaunchManager, |
| 2641 | ConfigSource: configSource, |
| 2642 | Authorized: e.Source.UserAuthorized(), |
| 2643 | OAuthHTTPClient: opts.OAuthHTTPClient, |
| 2644 | }, workspaceRoot) |
| 2645 | if e.Source.ProjectScoped() && strings.TrimSpace(spec.Dir) == "" { |
| 2646 | spec.Dir = workspaceRoot |
| 2647 | } |
| 2648 | applyMCPIsolation(&spec, workspaceRoot, opts) |
| 2649 | return spec |
| 2650 | } |
| 2651 | |
| 2652 | func pluginPackageOwners(cfg *config.Config) map[string]string { |
| 2653 | out := map[string]string{} |
| 2654 | if cfg == nil { |
| 2655 | return out |
| 2656 | } |
| 2657 | for _, configured := range cfg.Plugins { |
| 2658 | if owner, ok := cfg.PluginPackageOwner(configured.Name); ok { |
| 2659 | out[configured.Name] = owner |
| 2660 | } |
| 2661 | } |
| 2662 | return out |
| 2663 | } |
| 2664 | |
| 2665 | func skillMCPBindings(sk skill.Skill, reg *tool.Registry, specs []plugin.Spec, cachedTools map[string][]plugin.CachedTool, cacheKeyOK map[string]bool) []tool.MCPBinding { |
| 2666 | var out []tool.MCPBinding |
| 2667 | liveServers := map[string]bool{} |
| 2668 | if reg != nil { |
| 2669 | bindings := reg.MCPBindings() |
| 2670 | out = make([]tool.MCPBinding, 0, len(bindings)) |
| 2671 | for _, binding := range bindings { |
| 2672 | liveServers[binding.Server] = true |
| 2673 | } |
| 2674 | out = append(out, skill.ToolBindingsForSkill(sk, bindings)...) |
| 2675 | } |
| 2676 | // A valid cached schema also supplies stable bindings for an on-demand |
| 2677 | // package server before it is connected. The skill can then route through |
| 2678 | // use_capability without inventing Reasonix's canonical name. |
| 2679 | for _, spec := range specs { |
| 2680 | if spec.Package != sk.Plugin || liveServers[spec.Name] || !cacheKeyOK[spec.Name] { |
| 2681 | continue |
| 2682 | } |
| 2683 | for _, cached := range cachedTools[spec.Name] { |
| 2684 | visible := cached.Name |
| 2685 | if spec.StripRawPrefix != "" { |
| 2686 | visible = strings.TrimPrefix(visible, spec.StripRawPrefix) |
| 2687 | } |
| 2688 | out = append(out, tool.MCPBinding{ |
| 2689 | Package: spec.Package, |
| 2690 | Server: spec.Name, |
| 2691 | RawName: cached.Name, |
| 2692 | VisibleName: visible, |
| 2693 | CallableName: plugin.ModelToolName(spec.Name, visible), |
| 2694 | CapabilityID: "mcp-tool:" + spec.Name + "/" + cached.Name, |
| 2695 | }) |
| 2696 | } |
| 2697 | } |
| 2698 | return out |
| 2699 | } |
| 2700 | |
| 2701 | func applyMCPIsolation(spec *plugin.Spec, workspaceRoot string, opts PluginSpecOptions) { |
| 2702 | if spec == nil { |
| 2703 | return |
| 2704 | } |
| 2705 | // Authorized user MCP defaults to trusted host process mode. Confined mode |
| 2706 | // is opt-in for internal managed deployments/tests and is never selected by |
| 2707 | // ordinary install paths. |
| 2708 | if spec.ProcessMode == "" { |
| 2709 | spec.ProcessMode = plugin.MCPProcessHost |
| 2710 | } |
| 2711 | if strings.TrimSpace(opts.StateHome) == "" { |
| 2712 | return |
| 2713 | } |
| 2714 | stateDir := plugin.MCPStateDir(opts.StateHome, workspaceRoot, spec.Name) |
| 2715 | spec.StateDir = stateDir |
| 2716 | if spec.ResolvedProcessMode() != plugin.MCPProcessConfined { |
| 2717 | // Host mode still gets a private state/cache/temp tree; only the OS |
| 2718 | // command sandbox is omitted so local app integrations keep working. |
| 2719 | return |
| 2720 | } |
| 2721 | writerRoots := appendUniquePaths([]string{stateDir}, opts.WriterRoots...) |
| 2722 | spec.Sandbox = sandbox.Spec{ |
| 2723 | Mode: "enforce", WriteRoots: writerRoots, |
| 2724 | ForbidReadRoots: append([]string(nil), opts.ForbidReadRoots...), |
| 2725 | Network: opts.Network, MinimalWrites: true, |
| 2726 | } |
| 2727 | } |
| 2728 | |
| 2729 | func secondsDuration(seconds int) time.Duration { |
| 2730 | if seconds <= 0 { |
| 2731 | return 0 |
| 2732 | } |
| 2733 | return time.Duration(seconds) * time.Second |
| 2734 | } |
| 2735 | |
| 2736 | func toolTimeoutDurations(seconds map[string]int) map[string]time.Duration { |
| 2737 | if len(seconds) == 0 { |
| 2738 | return nil |
| 2739 | } |
| 2740 | out := make(map[string]time.Duration, len(seconds)) |
| 2741 | for name, sec := range seconds { |
| 2742 | name = strings.TrimSpace(name) |
| 2743 | if name == "" || sec <= 0 { |
| 2744 | continue |
| 2745 | } |
| 2746 | out[name] = time.Duration(sec) * time.Second |
| 2747 | } |
| 2748 | if len(out) == 0 { |
| 2749 | return nil |
| 2750 | } |
| 2751 | return out |
| 2752 | } |
| 2753 | |
| 2754 | func applyKnownPluginOverrides(specs []plugin.Spec, workspaceRoot string) []plugin.Spec { |
| 2755 | out := make([]plugin.Spec, len(specs)) |
| 2756 | for i, spec := range specs { |
| 2757 | out[i] = plugin.ApplyKnownOverrides(spec, workspaceRoot) |
| 2758 | } |
| 2759 | return out |
| 2760 | } |
| 2761 | |
| 2762 | func applyDefaultMCPCallTimeout(specs []plugin.Spec, timeout time.Duration) []plugin.Spec { |
| 2763 | if len(specs) == 0 || timeout <= 0 { |
| 2764 | return specs |
| 2765 | } |
| 2766 | out := make([]plugin.Spec, len(specs)) |
| 2767 | for i, spec := range specs { |
| 2768 | out[i] = spec |
| 2769 | if out[i].DefaultCallTimeout <= 0 { |
| 2770 | out[i].DefaultCallTimeout = timeout |
| 2771 | } |
| 2772 | } |
| 2773 | return out |
| 2774 | } |
| 2775 | |
| 2776 | func applyDefaultMCPStartupTimeout(specs []plugin.Spec, timeout time.Duration) []plugin.Spec { |
| 2777 | if len(specs) == 0 || timeout <= 0 { |
| 2778 | return specs |
| 2779 | } |
| 2780 | out := make([]plugin.Spec, len(specs)) |
| 2781 | for i, spec := range specs { |
| 2782 | out[i] = spec |
| 2783 | if out[i].DefaultStartupTimeout <= 0 { |
| 2784 | out[i].DefaultStartupTimeout = timeout |
| 2785 | } |
| 2786 | } |
| 2787 | return out |
| 2788 | } |
| 2789 | |
| 2790 | // MCPStartupNotice formats the warning shown when configured MCP servers failed |
| 2791 | // to connect, naming the first few; ok is false when none failed. |
| 2792 | func MCPStartupNotice(failures []plugin.Failure) (text, detail string, ok bool) { |
| 2793 | if len(failures) == 0 { |
| 2794 | return "", "", false |
| 2795 | } |
| 2796 | names := make([]string, 0, min(len(failures), 3)) |
| 2797 | details := make([]string, 0, len(failures)) |
| 2798 | for i, f := range failures { |
| 2799 | if i >= 3 { |
| 2800 | continue |
| 2801 | } |
| 2802 | names = append(names, f.Name) |
| 2803 | } |
| 2804 | for _, f := range failures { |
| 2805 | line := f.Name |
| 2806 | if strings.TrimSpace(f.Error) != "" { |
| 2807 | line += ": " + strings.TrimSpace(f.Error) |
| 2808 | } |
| 2809 | details = append(details, line) |
| 2810 | } |
| 2811 | more := "" |
| 2812 | if len(failures) > len(names) { |
| 2813 | more = fmt.Sprintf(" (+%d more)", len(failures)-len(names)) |
| 2814 | } |
| 2815 | return "Some MCP servers failed to start; run /mcp for details.", fmt.Sprintf("%d MCP server(s) failed to start: %s%s\n%s", |
| 2816 | len(failures), strings.Join(names, ", "), more, strings.Join(details, "\n")), true |
| 2817 | } |
| 2818 | |
| 2819 | func providerNames(cfg *config.Config) string { |
| 2820 | names := make([]string, len(cfg.Providers)) |
| 2821 | for i, p := range cfg.Providers { |
| 2822 | names[i] = p.Name |
| 2823 | } |
| 2824 | return strings.Join(names, "/") |
| 2825 | } |
| 2826 |