Scope is the dependency-free library primitive (packages/core/scope/src/index.ts and store.ts) that the registries and the loop build per-agent scoping on. It is the one non-service package in the core spine and sits below session/ and system-prompt/ in the module graph precisely so those packages can consume it without a cycle. It gives a registration context two meanings at once: per-agent visibility and shared lifetime ownership.
| Package | Owns |
|---|---|
@deepseek-ai/dsh-scope | ScopeKey, Scope, scopeTarget, createScope, bindScopeParent, the ScopedLayers store |
@deepseek-ai/dsh-agent | The live registry and agent.ctx, built on scope |
Global vs scoped: two flat levels
There is no multi-level inheritance tree for visibility. Registrations are either global (visible to every agent) or scoped (owned by exactly one scope key). The glossary is explicit: "Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with lineage data, never scope structure."
The ScopedLayers store enforces this: it owns an eagerly constructed global layer and lazily created exact-scope layers, keyed by ScopeKey.
export class ScopedLayers<L extends ScopeLayer> {
/** The eagerly constructed context-global layer. */
readonly global: L
private readonly scoped = new Map<ScopeKey, L>()
}ScopeKey is an opaque, identity-compared object. The shipped loop uses the live Agent object as its own key, but the primitive never inspects the object — any identity-compared object works.
The exact-scope layer, with a scoped registry parent chain
Within the flat two-level model, there is still a parent chain (scopeParents) used for two scoped-registry purposes: scoped layering (chainLayers/merge) and event admission (scopeTarget). A scoped key may bind an enclosing scope via bindScopeParent(parent), which is cycle-checked and cannot be re-linked by anyone but the original binder. The rule that keeps this "flat": events and registrations flow up the chain, never down — a listener owned by an enclosing scope receives every descendant's events, but a tag below the dispatch key stays excluded.
The dispatch carrier: scopeTarget
Event dispatch routes by carrier. scopeTarget(base, key) builds an opaque receiver that preserves the base's existing Cordis filter, admits untagged listeners globally, and admits tagged listeners for a matching key or any of its ancestors.
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
const carrier = {
[CordisContext.filter](ctx: Context): boolean {
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
if (tag === undefined) return true // untagged listener admits globally
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === tag) return true // the subject's own key, or an ancestor
}
return false
},
}
carrierKeys.set(carrier, key)
return carrier as unknown as Scoped<T>
}Registry-subject events are unfiltered. agent/created, agent/disposed, agent/status, and friends all declare their this as a Scoped<Agent> carrier built with scopeTarget(agent, agent) — the subject is the key, so dispatch includes a scope-filter that reaches the subject's listeners. But a notification such as system-prompt/change is emitted with an unscoped subject, so it reaches everyone (a global change affects every scope).
Owned registration context: the Scope class surface
createScope(ctx, key) mints a scoped Context that inherits the mounting plugin's dependency API and owns every registration made through it. The real Scope class surface:
export interface Scope {
/** Context through which scope-owned registrations are made. */
ctx: Context
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
rawDispose: () => Promise<void> | void
/** Dispose every scope-owned registration; racing calls await the same completion. */
dispose(): Promise<void>
}export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
return {
ctx: scoped,
rawDispose: fiber.dispose,
dispose: () => (disposing ??= quiesceFiber(fiber)),
}
}rawDispose preserves the exact Cordis disposer identity needed by an ordered composite effect (used by the async agent factory to tear the agent down in order with its session); dispose() is the shared quiescence boundary for direct and racing callers.
Scoped dispatch rule
The scoped dispatch rule in one sentence: a subject carrier admits untagged listeners plus the subject's own (and ancestor) tagged listeners. Concretely, ctx.agents.register(agent) emits agent/created with this = scopeTarget(agent, agent), so an agent-scoped listener (registered through agent.ctx) receives only that agent's lifecycle events, while an unscoped listener sees them all.
Shadowing: most-specific-wins
Shadowing is how the persona and per-agent tool variants work. Within one registry, merge(scope, pick) materializes the global named entries followed by scope-chain shadows, farthest ancestor first, so the nearest scope's entry wins a name:
merge<V>(scope: ScopeKey | undefined, pick: (layer: L) => NamedEntries<V>): Map<string, V> {
const merged = new Map(pick(this.global).entries())
for (const layer of this.chainLayers(scope)) {
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
}
return merged
}A scoped deployment:persona section replaces the global one for that scope alone; a scoped tool with the same name hides its global twin for that agent. .peek(scope) is deliberately chain-blind — it reads one scope's own contributions and never an ancestor's, so restrictions and guards are scoped exactly.
A worked example: persona and per-agent tools
Global vs scoped becomes concrete in the prompt assembly. A deployment mounts a global persona (order-0 deployment:persona section) through plain plugin config. A single agent preset wants its own persona and an extra tool:
// host plane (global, visible to every agent)
systemPrompt.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: globalPersona })
systemPrompt.tools(() => ({ schemas: [sharedTool] }))
// agent plane (agent ctx, visible only to that agent)
agentCtx.systemPrompt.section({ name: PERSONA_SECTION, order: 0, text: presetPersona }) // shadows global
agentCtx.systemPrompt.tools(() => ({ schemas: [agentTool] })) // merged scoped
agentCtx.tools.restrict({ deny: [sharedTool.name] }) // filters globalmerge(agent, pick) yields the persona section whose name wins (the scoped one replaces the global), and both tool providers contribute (global providers and matching scoped providers both run, per the assembly flow). After restrict, sharedTool is invisible to and non-executable by that one agent. Everything registered through agent.ctx unwinds when the agent's scope disposes — the same mechanism that owns the tool, prompt, and restriction registrations.
Restrictions: compose by intersection
A restriction (tools.restrict(filter)) filters the global tool set for one scope, and multiple restrictions compose by intersection (an allow/deny filter must let the tool through every matching restriction to remain visible). A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one; scope-local tool registrations are merged after the filter. In the registry's ScopedLayers, restrictions live in the scope's own layer (.peek(scope)), chain-blind, so an agent's restrictions never silently pick up an ancestor's.
The setup window
The setup window is the creation slot where a creator composes an agent's scoped world: CreateAgentOptions.setup(agentCtx). It runs after the scope and agent objects exist but before the agent or session is published, agent/session-start fires, or the first prompt is assembled. Setup registers; it never drives the agent. A setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id.
Lineage is data, not scope
Lineage — parent/child facts — is carried as data, never as scope structure: parentSession, the durable delegationDepth, and the runtime subagentDepth. Because scoped registrations do not inherit down to subagents, a deployment expresses subtree behavior by reading that data, not by relying on scope parenting. The parent chain on a scope key is used for scoped registry layering and event admission across a composed standing scope, which is a different concern from child-agent lineage.
Further reading
- The agent loop — how the loop mints its own scope keyed on the live
ReactLoopAgent. - Session management — the scoped store that owns each agent's event log.
- System prompt assembly — how scoped sections/variables shadow globals.
- Tool presentation — scoped tools, restriction filtering, and
tools/presentAs. - Repo source:
packages/core/scope/src/index.ts,packages/core/scope/src/store.ts. - Official scaffold:
docs/subsystems/scope.md,docs/glossary.md(agent-scope, lineage, shadowing, restriction, setup-window entries).