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