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