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