docs: agent-scope RFC, CONTEXT.md glossary, architecture scope section, README sync
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.
This commit is contained in:
@@ -123,6 +123,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 |
|
||||
| [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 |
|
||||
| [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 |
|
||||
| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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, below `dsh-session`/`dsh-system-prompt` in 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 cordis `Context.filter`, composing the base's own filter, branded `Scoped<T>` and runtime-marked for the dev invariants; `Scope.rawDispose` exposes the exact cordis disposer so a composite effect nests the scope's teardown at its yield position; `scopeHost` is 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 throwing `create()` observes no half-created agent or session. `CreateAgentOptions.setup(agentCtx)` runs after the scope is minted and the agent registered, before `agent/session-start` and 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.tools` and `ctx.systemPrompt` file 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 scoped `deployment:persona`) and the per-agent tool-variant mechanism (a scoped `bash` with 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?)`, and `execute`, so what the model is shown, what a presenter renders, and what dispatches can never disagree; out-of-view execution is `UNKNOWN_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 fused `agentEvents(ctx, agent)`, which injects carrier and subject in one move so the correct dispatch is the shortest spelling), `session/created|event|flush` (carrier captured at `SessionStore.enter` from the entering context; `ctx.sessions.flush(session)` owns the awaited checkpoint dispatch), `tools/pre|post-execute` (by `exec.agent`), `system-prompt/assemble` (by `context.scope`; `assembleContextFor(agent)` builds the context), and `subagent/start|end` (by the delegating parent). Registry-subject notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) stay deliberately unfiltered. A listener registered through `agent.ctx` hears only its agent; plain plugin listeners keep hearing everything; `{ global: true }` bypasses filtering.
|
||||
- **Enforcement**: dev-invariants assert at cordis's `internal/dispatch` seam that every scoped-family dispatch carries a carrier keyed to the same subject its arguments name, and that an assembly context never carries `agent` without `scope`; the `verify-scoped-dispatch` gate 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's `finally` never runs); `structured.ts` collapses 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 `toolFilter` and 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.ctx` for 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.
|
||||
- `toolOrder` validates against the providers' pre-restriction `knownNames` universe, so a deployment order listing a global tool stays compatible with children that `restrict()` 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-agent `fs/*` policy, `llm/*` scoping, and background subagents (the parent-scope teardown effect is already shaped for them).
|
||||
Reference in New Issue
Block a user