The agent-scope-contexts RFC (implemented) records the decision tree: the dsh-scope primitive over cordis extend/Context.filter/no-op fibers, two-level flat scope with shadowing, restriction/grant semantics, the scoped-dispatch rule with fused helpers, the setup window, and the alternatives (explicit scope params, isolate, event-filtering-only, vendored support) with why each lost. CONTEXT.md pins the glossary. architecture.md gains the Agent Scope section, the dsh-scope spine row, the scoped turn-flow line, and an extension-table row (ceiling 1640→1790: the two-layer registration model is a new architectural axis; additions are condensed to pointers). READMEs of every touched package re-state their scoped facts; the stale structured-runtime README section is replaced by the scoped-registration description.
8.0 KiB
RFC: The agent is a registration scope
Status: implemented
Problem
The runtime is multi-agent — configuration can declare several agents, the ACP bridge creates one agent per client session, and the in-process subagent backends spawn/fork children as sibling agents on the same Cordis context — yet every extension surface was context-global. One tool registry fed every agent's prompt (a child spawned to summarize a file was offered bash, file-write, and the delegation tool itself, unbounded); one section list rendered the same persona for everyone (SubagentStartRequest could not express a per-child persona at all); every agent/*, session/*, and tools/* listener fired for every agent, so a decider waterfall written for one agent silently governed all of them unless its author remembered to self-filter. The gap was visible in the API: SubagentCapabilities.toolFilter was public vocabulary, yet every real provider declared toolFilter: false because per-agent tool visibility was unimplementable, and structured.ts carried a FIXME documenting the placeholder-schema/final-assembly-swap/refcount dance forced by global registration.
Decision
Make the agent a registration scope, using the framework's own machinery rather than per-registry bolt-ons:
dsh-scope(packages/core/scope, peer-deps cordis only, belowdsh-session/dsh-system-promptin the module-graph DAG):createScope(ctx, key)mints a tagged context over a synchronously-usable no-op-plugin fiber;scopeOf(ctx)reads the tag through the prototype chain;scopeTarget(base, key)builds the scope-filtered dispatch carrier over cordisContext.filter, composing the base's own filter, brandedScoped<T>and runtime-marked for the dev invariants;Scope.rawDisposeexposes the exact cordis disposer so a composite effect nests the scope's teardown at its yield position;scopeHostis the fail-loud test-side minter.- Ownership and visibility derive from ONE fact — which context a registration went through: the scope's fiber owns the disposal, and the tag decides who sees it. An explicit
{ scope }registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Agent.ctx: every live agent owns a scope context (key = the agent), minted inside the loop's composite lifecycle effect. Yield order gives teardown stop/drain → unregister → detach session → unwind scope; detach before the (async) scope unwind keeps store/registry rollback synchronous on every failure path, so a caller catching a throwingcreate()observes no half-created agent or session.CreateAgentOptions.setup(agentCtx)runs after the scope is minted and the agent registered, beforeagent/session-startand the loop start — setup REGISTERS the scoped world, it never drives (a dev invariant makes a pre-session-start turn a teaching error).- Two registration layers with shadowing:
ctx.toolsandctx.systemPromptfile a registration by the calling context's tag; a scoped tool/section/variable is visible to that agent alone, unwinds with it, and SHADOWS a same-named global contribution for that agent (most-specific-wins; within one layer duplicates still throw). Shadowing is the per-agent persona mechanism (a scopeddeployment:persona) and the per-agent tool-variant mechanism (a scopedbashwith the same model-facing name). tools.restrict({allow?, deny?}): a scoped, snapshot-at-registration mask over the GLOBAL tool surface with loud unknown-name validation; multiple restrictions intersect; scoped registrations are explicit grants that bypass restriction (what keeps a structured capture tool alive under an allow-list). One visibility function feeds prompt assembly,get(name, scope?), andexecute, so what the model is shown, what a presenter renders, and what dispatches can never disagree; out-of-view execution isUNKNOWN_TOOL, indistinguishable from nonexistent.- Scoped dispatch by rule: an event about one agent's activity dispatches with that agent's carrier — all
agent/*(via the fusedagentEvents(ctx, agent), which injects carrier and subject in one move so the correct dispatch is the shortest spelling),session/created|event|flush(carrier captured atSessionStore.enterfrom the entering context;ctx.sessions.flush(session)owns the awaited checkpoint dispatch),tools/pre|post-execute(byexec.agent),system-prompt/assemble(bycontext.scope;assembleContextFor(agent)builds the context), andsubagent/start|end(by the delegating parent). Registry-subject notifications (tools/change,system-prompt/change,subagent/provider-*) stay deliberately unfiltered. A listener registered throughagent.ctxhears only its agent; plain plugin listeners keep hearing everything;{ global: true }bypasses filtering. - Enforcement: dev-invariants assert at cordis's
internal/dispatchseam that every scoped-family dispatch carries a carrier keyed to the same subject its arguments name, and that an assembly context never carriesagentwithoutscope; theverify-scoped-dispatchgate pins the invariant table against the declaration docs so the two cannot drift. - The seam becomes honest: spawn/fork advertise
{ outputSchema, depthLimit, toolFilter, persona }all true (ACP all false); the driver composes the child's scoped world in the setup window; a parent-scope teardown effect links each child to its parent through the memoized handle (structured concurrency — a disposed parent reaches its subtree even if the delegating tool'sfinallynever runs);structured.tscollapses to scoped registrations with a call-keyed two-phase commit and one scoped prepend re-assert listener.
Alternatives considered
- Explicit scope parameters on every registration API (
tools.register(def, {agent})): forgettable — omitting the option is global, so leak-by-default survives; no lifecycle coupling; and it can express visible-to-X-disposed-with-Y, which is almost always a bug. - Per-agent
ctx.isolate()service instances: isolation is a bulkhead for co-hosting independent applications, not intra-app scoping. Resolution picks exactly one instance per name — "deployment tools plus my tools" needs a hand-built delegating merge registry per service — and single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per agent. - Event-filtering only (scoped listeners, global registries): leaves the model-visible surfaces — tool schemas, personas — unscoped, which is the half that makes
toolFilterand per-child personas impossible. - Vendored-cordis support (a first-class scope concept in the framework): more invasive vendor drift for no additional capability;
extend+Context.filter+ a no-op plugin fiber already compose the same semantics from public primitives.
Consequences
- Plugin authors get one new concept: register through
agent.ctxfor one agent, through your plugin context for everyone. The registration APIs are unchanged; scope-filtered events document themselves in the catalog. - The loop's dispatch discipline is enforced three ways:
Scoped<Agent>this-types make a bare subject a compile error, the fused helpers make the correct spelling the shortest, and the dev invariants throw on a mis-keyed or missing carrier at the dispatching call site. toolOrdervalidates against the providers' pre-restrictionknownNamesuniverse, so a deployment order listing a global tool stays compatible with children thatrestrict()it away (a typo still fails every assembly loudly).- A scoped listener's own disposer runs after the session leaves the store on teardown (detach precedes the scope unwind); it heard the final stop/drain flush while attached, so nothing durable is lost.
- Deliberately out of scope, buildable on the primitive with no core change: named profile registries (
agentCtx.plugin(...)already works), per-agentfs/*policy,llm/*scoping, and background subagents (the parent-scope teardown effect is already shaped for them).