Files
deepseek-harness/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md
T
Tianyi Cui cc24e79cd2 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.
2026-07-09 03:01:11 +08:00

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, 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).