返回 DeepSeek-Reasonix
runtime.go
根目录 / internal / boot / runtime.go
1 package boot
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "sync/atomic"
8
9 "reasonix/internal/command"
10 "reasonix/internal/control"
11 "reasonix/internal/extension"
12 "reasonix/internal/extension/dispatch"
13 "reasonix/internal/extension/protocol"
14 "reasonix/internal/extension/sidecar"
15 "reasonix/internal/extension/uihub"
16 "reasonix/internal/hook"
17 "reasonix/internal/plugin"
18 "reasonix/internal/provider"
19 "reasonix/internal/skill"
20 "reasonix/internal/tool"
21 )
22
23 // BuildResult is the full product of one runtime build: the ready-to-drive
24 // controller plus the extension kernel's frozen view of the same assembly.
25 // Snapshot is nil when kernel assembly failed — boot behavior is never
26 // allowed to depend on it (see build). Runtime is the snapshot's bound
27 // closable set; it holds the extension sidecar Manager when any v1 runtime
28 // package is installed, and its Close is chained into the controller's
29 // cleanup so sidecars die with their controller generation.
30 type BuildResult struct {
31 Controller *control.Controller
32 Snapshot *extension.RuntimeSnapshot
33 Runtime *extension.RuntimeSet
34 // Extensions is the started extension sidecar Manager (nil when no v1
35 // runtime package is installed). Its lifecycle belongs to Runtime; the
36 // field exists so later stages (and tests) can reach the live clients.
37 Extensions *sidecar.Manager
38 // Dispatcher is the frozen interceptor dispatcher for this runtime
39 // generation (nil when no sidecar started, or when snapshot assembly
40 // degraded). It is immutable and safe for concurrent turns; the
41 // controller receives it through SetExtensions right after assembly.
42 Dispatcher *dispatch.Dispatcher
43 // ExtensionUI is the host extension UI hub for this runtime generation
44 // (stage 8a; nil when no sidecar started). It is bound to the build's
45 // session ID and generation; the controller receives it through
46 // SetExtensionUI right after assembly, and a Rebuild creates a fresh hub
47 // on the new generation.
48 ExtensionUI *uihub.Hub
49 // ProviderResolver is the build's effective provider resolver: the
50 // caller-owned broker when Options.ProviderResolver is set, the local
51 // config-backed resolver otherwise, merged with any extension-hosted
52 // sidecar providers (stage 7). Plugin-namespaced refs route to the owning
53 // sidecar; every other ref resolves through the base resolver.
54 ProviderResolver provider.Resolver
55 }
56
57 // runtimeGeneration is the process-wide build generation counter. The first
58 // build gets generation 1 so 0 can mean "no snapshot" on a RuntimeSet built
59 // outside the kernel pipeline.
60 var runtimeGeneration atomic.Uint64
61
62 // nextRuntimeGeneration returns the next build generation. Generations pair
63 // with RuntimeSet.CloseIfGeneration so stale cleanup can never close a newer
64 // runtime's resources.
65 func nextRuntimeGeneration() uint64 { return runtimeGeneration.Add(1) }
66
67 // BuildRuntime runs the full boot assembly and returns the controller
68 // together with the extension kernel's frozen snapshot of the exact resources
69 // the build wired — tools, skills, commands, hooks, MCP servers, providers,
70 // and the composed system prompt. The snapshot is assembled from the in-hand
71 // objects the build itself produced (discovery never re-runs), so it cannot
72 // drift from what the controller actually uses, and it never makes an
73 // otherwise-successful build fail: an assembly error degrades to a nil
74 // Snapshot with a logged warning.
75 func BuildRuntime(ctx context.Context, opts Options) (*BuildResult, error) {
76 return build(ctx, opts)
77 }
78
79 // Build loads config, resolves the model(s), and returns a Controller wrapping a
80 // single Agent, or a two-model Coordinator when agent.planner_model is set. The
81 // returned controller owns plugin subprocesses; call Close (via Controller.Close)
82 // to release them.
83 //
84 // Build is the compatibility wrapper over BuildRuntime: frontends keep their
85 // existing signature. The runtime set is NOT closed here — it is chained into
86 // the controller's cleanup (the way LSP cleanup is chained), so extension
87 // sidecars live exactly as long as their controller.
88 func Build(ctx context.Context, opts Options) (*control.Controller, error) {
89 res, err := BuildRuntime(ctx, opts)
90 if err != nil {
91 return nil, err
92 }
93 return res.Controller, nil
94 }
95
96 // legacyAssembly carries the already-assembled runtime resources the kernel
97 // snapshot is built from. Every field is the exact object the rest of the
98 // build wired into the controller — the snapshot never re-derives anything.
99 type legacyAssembly struct {
100 systemPrompt string
101 registry *tool.Registry
102 skills []skill.Skill
103 commands []command.Command
104 hooks []hook.ResolvedHook
105 mcpSpecs []plugin.Spec
106 providers []provider.Descriptor
107 }
108
109 // extensionBoot carries the extension sidecar launch inputs into snapshot
110 // assembly: the session the sidecars serve, where non-fatal warnings go, and
111 // (stage 8a) the UI hub the sidecars' host/ui/* calls bind to.
112 type extensionBoot struct {
113 session protocol.SessionContext
114 onWarning func(string)
115 ui *uihub.Hub
116 }
117
118 func (p extensionBoot) warn(msg string) {
119 if p.onWarning != nil {
120 p.onWarning(msg)
121 }
122 }
123
124 // startExtensionPackages is the one sidecar launch path a build uses (a
125 // package-level seam so tests can prove each build starts its packages exactly
126 // once — preflight starts them and snapshot assembly reuses the same Manager).
127 var startExtensionPackages = sidecar.StartPackages
128
129 // preflightExtensionRuntimes starts the installed, enabled v1 runtime packages
130 // BEFORE the build resolves its model, so the extension-hosted providers their
131 // handshakes declare can serve first-boot model resolution, the executor, the
132 // planner, the guardian, and every sub-agent — not just the post-assembly
133 // resolver merge. It returns nil (and emits the installed-state warnings, the
134 // same ones StartPackages has always surfaced) when no runtime package is
135 // installed, keeping the no-extension path byte-identical. A required
136 // package's start failure is fatal (*sidecar.RequiredStartError); an optional
137 // failure degrades to warnings. When every started package was optional and
138 // failed, the empty Manager is retired here and nil is returned — again the
139 // pre-sidecar path.
140 //
141 // The caller owns the returned Manager until the RuntimeSet takes it over (or
142 // must close it on every error path in between).
143 func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionBoot) (*sidecar.Manager, error) {
144 if strings.TrimSpace(home) == "" {
145 return nil, nil
146 }
147 packages, loadWarnings := sidecar.LoadRuntimePackages(home)
148 if len(packages) == 0 {
149 // No v1 runtime packages: surface the installed-state warnings exactly
150 // as the in-assembly StartPackages call did, and take the untouched
151 // pre-sidecar path (no processes, no Manager).
152 for _, warning := range loadWarnings {
153 ext.warn(warning)
154 }
155 return nil, nil
156 }
157 // The stage-8a UI hub installs as each sidecar's host/ui/* handler; a nil
158 // hub keeps the "ui not available" default. Guard against the typed-nil
159 // trap: a nil *uihub.Hub must stay a nil UIHandler.
160 var ui sidecar.UIHandler
161 if ext.ui != nil {
162 ui = ext.ui
163 }
164 mgr, warnings, err := startExtensionPackages(ctx, home, ext.session, ui)
165 for _, warning := range warnings {
166 ext.warn(warning)
167 }
168 if err != nil {
169 // A required runtime failed: the Manager already shut down whatever
170 // started. The call site treats *RequiredStartError as fatal.
171 return nil, err
172 }
173 if len(mgr.Clients()) == 0 {
174 // Every package was optional and failed: retire the empty Manager and
175 // take the pre-sidecar path with warnings already surfaced.
176 _ = mgr.Close()
177 return nil, nil
178 }
179 return mgr, nil
180 }
181
182 // assembleLegacySnapshot freezes one boot's resources into an extension
183 // kernel snapshot. ConflictCollect is deliberate: the legacy sources already
184 // resolved their clashes inside their own discovery passes, and any residual
185 // dispute must land on the snapshot's Diagnostics without changing whether
186 // the session boots.
187 //
188 // Extension sidecars (stage 5b): mgr is the Manager preflightExtensionRuntimes
189 // started before model resolution; this function takes OVER its ownership —
190 // on every error path it closes mgr, and on success the Manager is registered
191 // into the RuntimeSet at activation, so it dies with its controller
192 // generation. Starting before the builder freezes is load-bearing: the
193 // handshake's declared providers and UI actions can only enter the catalog
194 // from a live handshake, and required-runtime failures must fail the build,
195 // which the activator seam (post-freeze) could only do after the catalog
196 // already settled.
197 //
198 // Dispatch wiring (stage 6b1): with live sidecars the boot also runs the
199 // system_prompt.build strategy BEFORE the freeze, so the frozen snapshot's
200 // SystemPrompt and CacheHash cover the final (possibly replaced) prompt —
201 // the hash honestly attributes the prompt the session was built with rather
202 // than a pre-strategy draft. A strategy failure fails the build because the
203 // slot owner is required-class by definition. After the freeze the boot
204 // builds the generation's Dispatcher from the snapshot's frozen chain and
205 // replacements, and broadcasts the system_prompt.build event with the final
206 // prompt to every observer. The replaced prompt lands in the snapshot; the
207 // build's tail (stage 6b2) swaps the live executor session to the same final
208 // prompt when it differs from the host-composed one the session was built
209 // with, so snapshot and session describe the same session before any turn.
210 // With a nil Manager the path is byte-identical to the pre-sidecar one: no
211 // processes, no contributions, no dispatcher, an empty runtime set.
212 func assembleLegacySnapshot(ctx context.Context, in legacyAssembly, generation uint64, ext extensionBoot, mgr *sidecar.Manager) (*extension.RuntimeSnapshot, *extension.RuntimeSet, *dispatch.Dispatcher, error) {
213 legacy := legacyContributions(in)
214 b := extension.NewBuilder().
215 WithGeneration(generation).
216 WithConflictPolicy(extension.ConflictCollect).
217 AddContributor(extension.ContributorFunc{
218 ContributorName: "boot-legacy",
219 Fn: func(context.Context) ([]extension.Contribution, error) {
220 return legacy, nil
221 },
222 })
223
224 prompt := in.systemPrompt
225 var dispatcher *dispatch.Dispatcher
226 // postFreeze runs after a successful b.Build when sidecars are live: it
227 // builds the generation's dispatcher from the snapshot's own frozen chain
228 // and replacements, then broadcasts system_prompt.build with the final
229 // prompt so observers see exactly what froze.
230 var postFreeze func(snap *extension.RuntimeSnapshot)
231 if mgr != nil && len(mgr.Clients()) > 0 {
232 managed := mgr
233 bindExtensionUI(ext.ui, managed, ext.warn)
234 sidecarContribs := managed.Contributions()
235 b.AddContributor(extension.ContributorFunc{
236 ContributorName: "boot-extension-runtimes",
237 Fn: func(context.Context) ([]extension.Contribution, error) {
238 return sidecarContribs, nil
239 },
240 })
241 b.WithActivator(func(_ context.Context, snap *extension.RuntimeSnapshot) (*extension.RuntimeSet, error) {
242 rs := extension.NewRuntimeSet(snap.Generation())
243 rs.Add(managed)
244 return rs, nil
245 })
246
247 // Resolve replacement-slot claims over the exact contribution set
248 // the builder will see, mirroring the kernel's claim pass, so a
249 // conflict fails BEFORE any strategy runs and the winning claims
250 // drive the pre-freeze strategy below.
251 claims, err := resolveReplacementClaims(legacy, sidecarContribs)
252 if err != nil {
253 _ = managed.Close()
254 return nil, nil, nil, err
255 }
256 required := requiredRuntimeSet(managed)
257 clients := sidecarClientResolver(managed)
258 dispatchOpts := dispatch.Options{Warn: ext.warn}
259 // system_prompt.build strategy: the slot's owner rules on the
260 // composed prompt before the snapshot freezes. The dispatcher used
261 // here needs no chain — RunStrategy consults only the replacements
262 // and clients.
263 if _, owned := claims[extension.SlotSystemPrompt]; owned {
264 strategyDispatcher := dispatch.New(nil, claims, clients, required, dispatchOpts)
265 payload := dispatch.SystemPromptPayload{Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot}
266 if err := strategyDispatcher.RunStrategy(ctx, extension.SlotSystemPrompt, extension.PointSystemPromptBuild, &payload); err != nil {
267 _ = managed.Close()
268 return nil, nil, nil, err
269 }
270 prompt = payload.Prompt
271 }
272
273 postFreeze = func(snap *extension.RuntimeSnapshot) {
274 dispatcher = dispatch.New(snap.InterceptorChain(), snap.Replacements(), clients, required, dispatchOpts)
275 dispatcher.Event(extension.PointSystemPromptBuild, dispatch.SystemPromptPayload{
276 Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot,
277 })
278 }
279 } else if mgr != nil {
280 // Defensive: preflight already retires client-less managers, but a
281 // caller-owned Manager must never leak through assembly either way.
282 _ = mgr.Close()
283 mgr = nil
284 }
285
286 b.WithSystemPrompt(prompt)
287 snap, runtimeSet, err := b.Build(ctx)
288 if err != nil {
289 if mgr != nil {
290 _ = mgr.Close()
291 }
292 return nil, nil, nil, err
293 }
294 if postFreeze != nil {
295 postFreeze(snap)
296 }
297 return snap, runtimeSet, dispatcher, nil
298 }
299
300 // resolveReplacementClaims replays the kernel's slot-claim pass (see
301 // resolveContributions) over the contribution set before the builder freezes:
302 // claims come from every contribution's SlotClaimer payload, winners and
303 // losers alike, and a second claimant is a *SlotConflictError.
304 func resolveReplacementClaims(groups ...[]extension.Contribution) (map[extension.Slot]extension.ContributionSource, error) {
305 claims := extension.NewReplaceClaims()
306 for _, group := range groups {
307 for _, ct := range group {
308 claimer, ok := ct.Payload.(extension.SlotClaimer)
309 if !ok {
310 continue
311 }
312 for _, slot := range claimer.ReplacementSlots() {
313 if err := claims.Claim(slot, ct.Source); err != nil {
314 return nil, err
315 }
316 }
317 }
318 }
319 return claims.Claims(), nil
320 }
321
322 // requiredRuntimeSet marks every started sidecar whose manifest declared the
323 // runtime required:true, the dispatcher's required-class input.
324 func requiredRuntimeSet(mgr *sidecar.Manager) map[string]bool {
325 clients := mgr.Clients()
326 out := make(map[string]bool, len(clients))
327 for _, client := range clients {
328 out[client.PluginID()] = client.Required()
329 }
330 return out
331 }
332
333 // sidecarClientResolver adapts the Manager to the dispatcher's client lookup.
334 // The dispatcher requires an untyped nil for missing clients; Manager.Client
335 // returns a typed *sidecar.Client.
336 func sidecarClientResolver(mgr *sidecar.Manager) func(pluginID string) dispatch.Client {
337 return func(pluginID string) dispatch.Client {
338 if client := mgr.Client(pluginID); client != nil {
339 return client
340 }
341 return nil
342 }
343 }
344
345 // bindExtensionUI finishes the stage-8a hub binding once sidecars are live:
346 // the client resolver routes later /<plugin>:<action> invocations and form
347 // submissions, and each client's handshake-declared UI actions enter the
348 // registry. An invalid declaration degrades to a warning (the plugin's other
349 // contributions are unaffected) rather than failing the build.
350 func bindExtensionUI(hub *uihub.Hub, mgr *sidecar.Manager, warn func(string)) {
351 if hub == nil || mgr == nil {
352 return
353 }
354 hub.SetResolver(func(pluginID string) uihub.ActionClient {
355 if client := mgr.Client(pluginID); client != nil {
356 return client
357 }
358 return nil
359 })
360 for _, client := range mgr.Clients() {
361 actions := client.Handshake().UIActions
362 if len(actions) == 0 {
363 continue
364 }
365 if err := hub.RegisterActions(client.PluginID(), actions); err != nil {
366 warn(fmt.Sprintf("%s: extension UI actions not registered: %v", client.PluginID(), err))
367 }
368 }
369 }
370
371 // legacyContributions maps the assembled runtime resources to kernel
372 // contributions, reusing the extension package's per-kind mappers so scope
373 // attribution stays in one place. Contributions whose IDs predate or violate
374 // the kernel's ID contract are skipped: one pathological legacy name must not
375 // take down the whole snapshot, and the live runtime is untouched either way.
376 func legacyContributions(in legacyAssembly) []extension.Contribution {
377 specByName := map[string]plugin.Spec{}
378 for _, spec := range in.mcpSpecs {
379 specByName[mcpNormalizedServerName(spec.Name)] = spec
380 }
381 var out []extension.Contribution
382 for _, entry := range in.registry.ContractEntries() {
383 if !extension.ValidToolID(entry.Name) {
384 // Legacy MCP name normalization preserves uppercase characters,
385 // which predates the kernel's lowercase tool-ID contract. Such a
386 // tool keeps working in the live registry; it is skipped here
387 // rather than failing the whole snapshot.
388 continue
389 }
390 out = append(out, extension.Contribution{
391 Kind: extension.KindTool,
392 ID: entry.Name,
393 Source: legacyToolSource(entry.Name, specByName),
394 Payload: entry,
395 })
396 }
397 for _, sk := range in.skills {
398 if !kernelID(sk.SlashName()) {
399 continue
400 }
401 out = append(out, extension.SkillContribution(sk))
402 }
403 for _, cmd := range in.commands {
404 if !kernelID(cmd.Name) {
405 continue
406 }
407 out = append(out, extension.CommandContribution(cmd))
408 }
409 perEvent := map[hook.Event]int{}
410 for _, h := range in.hooks {
411 seq := perEvent[h.Event]
412 perEvent[h.Event]++
413 out = append(out, extension.HookContribution(h, seq))
414 }
415 for _, spec := range in.mcpSpecs {
416 if !kernelID(spec.Name) {
417 // A server name outside the kernel ID contract cannot be a
418 // catalog entry; the server's tools still attribute through
419 // specByName above.
420 continue
421 }
422 out = append(out, extension.MCPServerContribution(spec))
423 }
424 for _, desc := range in.providers {
425 if !extension.IsProviderRef(desc.Ref) {
426 // The kernel keys providers on <name>/<model> refs; legacy
427 // catalogs can carry a bare provider name or a model that itself
428 // contains a slash. Those entries stay resolvable through the
429 // ordinary provider path but cannot be catalogued in a v1
430 // snapshot.
431 continue
432 }
433 out = append(out, extension.ProviderContribution(desc))
434 }
435 return out
436 }
437
438 // kernelID mirrors the generic ID hygiene the kernel validates for every
439 // kind (non-empty, no whitespace). The kernel's parse step trims first, so
440 // only interior whitespace or emptiness disqualifies.
441 func kernelID(id string) bool {
442 id = strings.TrimSpace(id)
443 return id != "" && !strings.ContainsAny(id, " \t\n")
444 }
445
446 // legacyToolSource attributes a registry tool to its origin. MCP-backed tools
447 // (the mcp__<server>__<tool> namespace) belong to the server's spec: a
448 // package-owned server attributes to the plugin package, otherwise to the
449 // server itself, both at the plugin tier. Everything else — and any MCP tool
450 // whose server can no longer be matched to a spec — stays at the builtin
451 // tier: the registry is the compile-time default surface, and an
452 // unattributable entry must not masquerade as a higher one. Tool IDs are
453 // unique inside one registry, so in stage 3a the scope is provenance only and
454 // never decides a shadow race.
455 func legacyToolSource(name string, specByName map[string]plugin.Spec) extension.ContributionSource {
456 if server, _, ok := tool.SplitMCPName(name); ok {
457 if spec, found := specByName[server]; found {
458 pluginID := strings.TrimSpace(spec.Package)
459 if pluginID == "" {
460 pluginID = spec.Name
461 }
462 origin := strings.TrimSpace(spec.ConfigSource)
463 if origin == "" {
464 origin = "mcp"
465 }
466 return extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pluginID, Origin: origin}
467 }
468 }
469 return extension.ContributionSource{Scope: extension.ScopeBuiltin, Origin: "builtin"}
470 }
471
472 // mcpNormalizedServerName returns the server name as it appears inside
473 // mcp__<server>__<tool> registry names, i.e. after the plugin package's name
474 // normalization (invalid characters replaced, collision hash appended).
475 func mcpNormalizedServerName(name string) string {
476 return strings.TrimSuffix(strings.TrimPrefix(plugin.ToolPrefix(name), tool.MCPNamePrefix), "__")
477 }
478
479 // enabledMCPSpecs returns the deduplicated set of MCP server specs a build
480 // enabled: the configured eager/background tiers plus host-session extras, or
481 // the on-demand connect set under the Economy profile (whose servers enter
482 // the registry only through connect_tool_source). Names are deduplicated so
483 // the kernel catalog holds one entry per server.
484 func enabledMCPSpecs(configSpecs, extraSpecs []plugin.Spec, onDemandNames []string, onDemandSpecs map[string]plugin.Spec, tokenEconomy bool) []plugin.Spec {
485 var out []plugin.Spec
486 seen := map[string]bool{}
487 add := func(spec plugin.Spec) {
488 name := strings.TrimSpace(spec.Name)
489 if name == "" || seen[name] {
490 return
491 }
492 seen[name] = true
493 out = append(out, spec)
494 }
495 if tokenEconomy {
496 for _, name := range onDemandNames {
497 add(onDemandSpecs[name])
498 }
499 return out
500 }
501 for _, spec := range configSpecs {
502 add(spec)
503 }
504 for _, spec := range extraSpecs {
505 add(spec)
506 }
507 return out
508 }
509
510 // gateExtensionUIRequest serves a sidecar's blocking host/ui/request once the
511 // session controller exists. A sidecar may legally ask right after
512 // extension/initialized — while the build is still assembling the controller —
513 // so the preflight hub cannot answer immediately. Rather than failing the
514 // prompt (which would deadlock an extension waiting on its own startup
515 // request), the gate waits for the controller to become ready, for the build
516 // to fail, or for the request context to cancel. serve is only invoked after
517 // load reports a controller.
518 func gateExtensionUIRequest(reqCtx context.Context, load func() *control.Controller, ready <-chan struct{}, failed <-chan struct{}, serve func(*control.Controller) (map[string]any, bool, error)) (map[string]any, bool, error) {
519 if c := load(); c != nil {
520 return serve(c)
521 }
522 select {
523 case <-ready:
524 c := load()
525 if c == nil {
526 return nil, false, fmt.Errorf("extension UI request: controller readiness signalled without a controller")
527 }
528 return serve(c)
529 case <-failed:
530 return nil, false, fmt.Errorf("extension UI request arrived but the session build failed")
531 case <-reqCtx.Done():
532 return nil, false, reqCtx.Err()
533 }
534 }
535
535 lines GO