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