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:
@@ -14,6 +14,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is
|
||||
|
||||
| ctx key | Package | Role |
|
||||
|---|---|---|
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) |
|
||||
| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
|
||||
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
|
||||
@@ -58,7 +59,7 @@ A **session** is one agent's append-only event log. A **turn** drains one queued
|
||||
### Turn Flow
|
||||
|
||||
```text
|
||||
create agent -> emit agent/session-start(source)
|
||||
create agent -> mint agent scope (agent.ctx) -> run creation setup -> emit agent/session-start(source)
|
||||
forever:
|
||||
wait for queued messages
|
||||
emit agent/status(running)
|
||||
@@ -103,6 +104,10 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
|
||||
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`.
|
||||
|
||||
### Agent Scope
|
||||
|
||||
Every live agent owns a scope context, `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), key = the agent). Registrations through it — tools, prompt sections/variables, listeners, `tools.restrict()` masks — are visible to that agent alone, SHADOW same-named global contributions for it (per-agent personas and tool variants), and unwind with the agent; an `agent.ctx` listener hears only that agent's dispatches, while events about one agent dispatch with its scope carrier. `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation (the subagent seam's `persona`/`toolFilter`) — setup registers, never drives. Dev invariants enforce carrier/subject identity; `verify-scoped-dispatch` pins enforced ⇔ documented. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md).
|
||||
|
||||
## State And Model Surface
|
||||
|
||||
### Session Log
|
||||
@@ -145,5 +150,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi
|
||||
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
|
||||
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
|
||||
| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
|
||||
| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) |
|
||||
|
||||
The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
|
||||
|
||||
@@ -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