Переглянути джерело

feat(core): scope-aware registries and session dispatch carriers

dsh-tools and dsh-system-prompt gain a per-scope registration layer over
dsh-scope: a registration through a scoped context files into that scope,
shadows a same-named global contribution for that scope (per-agent persona
and tool variants), and unwinds with the scope. tools.restrict() masks the
global surface per scope (snapshot-at-registration, loud unknown-name
validation, intersection composition; scoped grants bypass). One visibility
function feeds schemas/get/execute, so prompt, presentation, and dispatch
can never disagree; out-of-view executes as UNKNOWN_TOOL.

Prompt tool providers now receive the AssembleContext and return
{schemas, knownNames}: toolOrder validates against the pre-restriction name
universe (a typo fails every assembly loudly) while ordering operates on
the post-restriction schemas (a restricted-away tool is a normal absence).

dsh-session captures each session's dispatch carrier at enter() from the
entering context's scope tag, and the new sessions.flush(session) owns the
awaited session/flush dispatch. tools/pre|post-execute and
system-prompt/assemble dispatch with scope carriers keyed by their subject;
session/created|event|flush by the owning session's scope.
Tianyi Cui 2 місяців тому
батько
коміт
3d16026eb0

+ 1 - 1
packages/core/agent-loop/tests/tool-order.spec.ts

@@ -107,7 +107,7 @@ describe('loop-level canonical tool order', () => {
     agent.send([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(0)
-    expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
+    expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
     expect(foldRequestHeader(agent.session.events)).toBeUndefined()
     const end = agent.session.events.find(e => e.type === 'turn/end')
     expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })

+ 2 - 0
packages/core/session/package.json

@@ -24,11 +24,13 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-scope": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }
 }

+ 68 - 10
packages/core/session/src/index.ts

@@ -9,6 +9,8 @@
 import { Context, Service } from 'cordis'
 import { isAbsolute } from 'node:path'
 import { deepFreeze } from '@deepseek-ai/dsh-llm'
+import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
+import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
 import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
 import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
@@ -33,28 +35,48 @@ declare module 'cordis' {
   interface Events {
     /**
      * A session was created in the store.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
+     * session's owner scope, captured when the session was ENTERED (an agent's
+     * session is entered through `agent.ctx`, so its events dispatch in that
+     * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
+     * subject-less). A listener registered through `agent.ctx` hears only that
+     * agent's sessions; a plain plugin listener hears every session.
      * @param session - the session just entered and announced.
      * @mode emit
      */
-    'session/created'(session: Session): void
+    'session/created'(this: Scoped<Session>, session: Session): void
     /**
      * An event was appended to a session log (sync, fire-and-forget). This is
      * the per-append feed a UI or invariant plugin tails.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
+     * session's owner scope, captured when the session was ENTERED (an agent's
+     * session is entered through `agent.ctx`, so its events dispatch in that
+     * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
+     * subject-less). A listener registered through `agent.ctx` hears only that
+     * agent's sessions; a plain plugin listener hears every session.
      * @param session - the session whose log grew.
      * @param event - the appended event, exactly as recorded.
      * @mode emit
      */
-    'session/event'(session: Session, event: SessionEvent): void
+    'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
     /**
      * Awaited durability checkpoint. The agent loop awaits
-     * `ctx.parallel('session/flush', session)` at every turn end; persistence
+     * `ctx.sessions.flush(session)` at every turn end; persistence
      * plugins (JSONL, SQLite) drain their write-behind buffers here and on
      * fiber dispose. Awaited (parallel), not a waterfall: every listener runs
-     * and the loop waits for all of them, but none can veto.
+     * and the caller waits for all of them, but none can veto. Dispatch it
+     * through {@link SessionStore.flush} — the store owns the carrier — never
+     * via a raw `ctx.parallel`.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
+     * session's owner scope, captured when the session was ENTERED (an agent's
+     * session is entered through `agent.ctx`, so its events dispatch in that
+     * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
+     * subject-less). A listener registered through `agent.ctx` hears only that
+     * agent's sessions; a plain plugin listener hears every session.
      * @param session - the session whose buffered events must reach durable storage.
      * @mode parallel
      */
-    'session/flush'(session: Session): Promise<void> | void
+    'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
   }
 }
 
@@ -404,6 +426,14 @@ export class SessionForkError extends Error {
  */
 export class SessionStore extends Service {
   private store = new Map<SessionId, Session>()
+  /**
+   * Each live session's dispatch carrier, captured at {@link enter} from the
+   * ENTERING context's scope tag (an agent session is entered through
+   * `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
+   * subject-less carrier). WeakMap so a detached session drops its carrier
+   * with the entry.
+   */
+  private carriers = new WeakMap<Session, Scoped<Session>>()
   private counter = 0
 
   constructor(ctx: Context) {
@@ -498,7 +528,15 @@ export class SessionStore extends Service {
    */
   enter(session: Session): () => void {
     if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
-    session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
+    // The carrier is decided HERE, once, from the ENTERING context's scope tag
+    // (`this.ctx` is the caller's context — the tracker mechanism): every
+    // session/created|event|flush dispatch for this session uses it, so the
+    // session's whole event feed is scope-filtered consistently. The base is
+    // the session itself (scoped listeners' `this` is the session).
+    const carrier = scopeTarget(session, scopeOf(this.ctx))
+    this.carriers.set(session, carrier)
+    const emitCtx = this.ctx
+    session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
     this.store.set(session.id, session)
     return () => {
       session.onAppend = undefined
@@ -506,12 +544,32 @@ export class SessionStore extends Service {
     }
   }
 
-  /** Emit `session/created` for an {@link enter}ed session. Separate from
-   * {@link enter} so the caller can yield the detach disposer first (rollback
-   * safety — see {@link enter}).
+  /** Emit `session/created` for an {@link enter}ed session (with the carrier
+   * {@link enter} captured). Separate from {@link enter} so the caller can
+   * yield the detach disposer first (rollback safety — see {@link enter}).
    * @param session - the entered session to announce to listeners. */
   announce(session: Session): void {
-    this.ctx.emit('session/created', session)
+    this.ctx.emit(this.carrierFor(session), 'session/created', session)
+  }
+
+  /**
+   * Dispatch the awaited `session/flush` durability checkpoint for `session`,
+   * with the carrier captured at {@link enter}. THE flush entry point: the
+   * store owns the carrier, so callers (the loop's turn-end checkpoint, idle
+   * injection, teardown drains) must come through here rather than dispatch a
+   * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
+   * scoped-dispatch invariant can pin it.
+   * @param session - the session whose buffered events must reach durable storage.
+   * @returns resolves when every flush listener has settled; rejects if one rejects.
+   */
+  async flush(session: Session): Promise<void> {
+    await this.ctx.parallel(this.carrierFor(session), 'session/flush', session)
+  }
+
+  /** The carrier {@link enter} captured, or a subject-less one for a session
+   * never entered (defensive: dispatch stays filtered either way). */
+  private carrierFor(session: Session): Scoped<Session> {
+    return this.carriers.get(session) ?? scopeTarget(session, undefined)
   }
 
   /**

+ 112 - 0
packages/core/session/tests/scoped.spec.ts

@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
+import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+
+async function mount(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  return ctx
+}
+
+async function mintScope(ctx: Context, name: string): Promise<Scope> {
+  let scope!: Scope
+  // The scoped context resolves services through the MINTING plugin's
+  // dependency chain — the minter must inject what scope holders will reach.
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
+    { inject: ['sessions'] }))
+  return scope
+}
+
+/** The key a test scope was minted with. */
+function keyOf(scope: Scope): ScopeKey {
+
+  return scopeOf(scope.ctx)!
+}
+
+describe('session dispatch carriers', () => {
+  it('a session entered through a scoped context dispatches its events in that scope', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'owner')
+    const otherScope = await mintScope(ctx, 'other')
+
+    const heard: string[] = []
+    ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`))
+    scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`))
+    otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`))
+    scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`))
+    otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
+
+    const session = scope.ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+
+    expect(heard).toEqual([
+      `owner-created:${session.id}`,
+      'global:turn/start',
+      'owner:turn/start',
+    ])
+  })
+
+  it('a bare session dispatches subject-less: scoped listeners never hear it', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'owner')
+    const heard: string[] = []
+    ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`))
+    scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
+
+    const bare = ctx.sessions.create()
+    bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(heard).toEqual(['global:turn/start'])
+  })
+})
+
+describe('sessions.flush()', () => {
+  it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'owner')
+    const flushed: string[] = []
+    ctx.on('session/flush', async (session: Session) => {
+      await Promise.resolve()
+      flushed.push(`global:${session.id}`)
+    })
+    scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
+
+    const owned = scope.ctx.sessions.create()
+    const bare = ctx.sessions.create()
+    await ctx.sessions.flush(owned)
+    await ctx.sessions.flush(bare)
+
+    // Parallel dispatch: listener completion order is unspecified (the global
+    // listener awaits a microtask) — assert set membership per flush instead.
+    expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`])
+    expect(flushed.slice(2)).toEqual([`global:${bare.id}`])
+  })
+
+  it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
+    const ctx = await mount()
+    ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
+    const session = ctx.sessions.create()
+    await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
+  })
+
+  it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'owner')
+    const flushed: string[] = []
+    ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
+    scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
+
+    const detached = ctx.sessions.prepare()
+    await ctx.sessions.flush(detached)
+    expect(flushed).toEqual([`global:${detached.id}`])
+  })
+
+  it('keyOf sanity: distinct scopes carry distinct keys', async () => {
+    const ctx = await mount()
+    const a = await mintScope(ctx, 'a')
+    const b = await mintScope(ctx, 'b')
+    expect(keyOf(a)).not.toBe(keyOf(b))
+  })
+})

+ 3 - 0
packages/core/session/tsconfig.json

@@ -19,6 +19,9 @@
     },
     {
       "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/scope"
     }
   ]
 }

+ 2 - 0
packages/core/system-prompt/package.json

@@ -23,6 +23,7 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-scope": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "dependencies": {
@@ -30,6 +31,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }
 }

+ 188 - 58
packages/core/system-prompt/src/index.ts

@@ -14,6 +14,8 @@
 
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
+import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
+import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
 import type { ToolSchema } from '@deepseek-ai/dsh-llm'
 
 declare module 'cordis' {
@@ -30,15 +32,23 @@ declare module 'cordis' {
      * @param assembly - the assembly built from the registered sections, tool
      *   providers, and variable providers; listeners may mutate it or return a
      *   replacement.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
+     * by `context.scope` — a listener registered through `agent.ctx` fires only
+     * for that agent's assemblies; a plain plugin listener fires for every
+     * assembly (scope-less ones included, dispatched subject-less).
      * @param context - the per-assembly {@link AssembleContext} the caller
      *   passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
      *   is for), so a listener can filter or extend per agent.
      * @mode waterfall
      */
-    'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
+    'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
     /**
      * A section, tool provider, or variable provider was registered or
-     * unregistered (the assembly inputs changed).
+     * unregistered (the assembly inputs changed — possibly for one scope
+     * only). An UNFILTERED registry-subject notification, deliberately not
+     * scope-filtered dispatch: a global change concerns every agent's next
+     * assembly, so a scoped listener subscribing here sees every change, not
+     * just its own scope's.
      * @mode emit
      */
     'system-prompt/change'(): void
@@ -47,13 +57,24 @@ declare module 'cordis' {
 
 /**
  * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
- * Declared empty here so this package stays agnostic of who assembles;
- * merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so
- * section text and variable providers can be functions of the calling agent.
- * Every field is optional by nature: a bare `assemble()` (tests, diagnostics)
- * carries an empty context, and providers must tolerate absent fields.
+ * Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent`
+ * declares the `agent` field, so section text and variable providers can be
+ * functions of the calling agent. Every field is optional by nature: a bare
+ * `assemble()` (tests, diagnostics) carries an empty, scope-less context, and
+ * providers must tolerate absent fields.
  */
-export interface AssembleContext {}
+export interface AssembleContext {
+  /**
+   * The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped
+   * sections/variables/tool-providers registered through this key's context
+   * join the assembly (shadowing same-named global contributions), and the
+   * `system-prompt/assemble` waterfall dispatches in this scope. The agent
+   * loop sets it to the agent (alongside the `agent` DX field — never set
+   * `agent` without `scope`; the dev invariants flag the mismatch). Absent =
+   * a scope-less assembly: global layer only, subject-less dispatch.
+   */
+  scope?: ScopeKey
+}
 
 /** One contributed section of the system prompt (registry input). */
 export interface PromptSection {
@@ -83,6 +104,23 @@ export interface AssembledSection {
   text: string
 }
 
+/**
+ * What one tool-schema provider contributes to an assembly
+ * ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction
+ * visible set for the assembly's scope — exactly what the model may be shown.
+ * `knownNames` is its PRE-restriction name universe: the set configured names
+ * (`toolOrder`) are validated against, so a config typo fails loud while a
+ * restricted-away tool stays a normal, non-erroneous absence. Omitted,
+ * `knownNames` defaults to the names of `schemas` (right for providers with no
+ * restriction concept).
+ */
+export interface ToolProviderResult {
+  /** The schemas this provider contributes to THIS assembly. */
+  schemas: ToolSchema[]
+  /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
+  knownNames?: readonly string[]
+}
+
 /**
  * The assembled prompt.
  *
@@ -146,23 +184,26 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
  * list, plain lexicographic name order; with one, listed names take their
  * listed position and every unlisted tool lands at the
  * {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
- * name with no collected tool throws — misconfiguration fails loud, and this
- * is the earliest moment the registered tool set exists to check against
- * (tool plugins register after the service constructs, so load time is too
- * early): the assembly rejects, failing the caller's turn before any model
- * request. Never drops a tool, and both sorts are stable, so tools sharing a
- * name keep their collection order.
+ * name outside `knownNames` — the providers' PRE-restriction name universe —
+ * throws: misconfiguration fails loud, and each assembly is the earliest
+ * moment the registered tool set exists to check against (tool plugins
+ * register after the service constructs, so load time is too early); the
+ * assembly rejects, failing the caller's turn before any model request. A
+ * listed name that is KNOWN but not collected (a tool restricted away for
+ * this assembly's scope) is a normal absence: its position simply
+ * contributes nothing — `toolOrder` stays compatible with per-agent
+ * `restrict()` masks. Never drops a collected tool, and both sorts are
+ * stable, so tools sharing a name keep their collection order.
  */
-function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
+function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
   const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
   if (reserved !== undefined) {
     throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
   }
   if (toolOrder === undefined) return tools.sort(compareToolNames)
-  const registered = new Set(tools.map(tool => tool.name))
-  const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
+  const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
   if (unknown.length > 0) {
-    throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
+    throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`)
   }
   const listed = new Set(toolOrder)
   const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
@@ -181,7 +222,10 @@ export interface Config {
    * The deployment's persona — the ONE deployment-authored fragment of the
    * system prompt, rendered as the order-0 `deployment:persona` section
    * (after the harness identity, before all tool guidance). Every agent in
-   * the context shares it, subagents included. Template, not free-form text:
+   * the context shares it by default; a per-agent persona is a SCOPED section
+   * of the same name registered through that agent's `agent.ctx` (it shadows
+   * this one for that agent — the subagent seam's `persona` request field does
+   * exactly that). Template, not free-form text:
    * every complete `{{…}}` group is interpreted strictly against the
    * registered prompt variables (the shipped agent loop registers `{{model}}`
    * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
@@ -301,8 +345,12 @@ export class SystemPrompt extends Service {
   })
 
   private sections: PromptSection[] = []
-  private toolProviders: (() => ToolSchema[])[] = []
+  private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
   private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
+  /** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
+  private scopedSections = new Map<ScopeKey, PromptSection[]>()
+  private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
+  private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
   private readonly toolOrder: string[] | undefined
 
   constructor(ctx: Context, public config: Config) {
@@ -330,27 +378,44 @@ export class SystemPrompt extends Service {
 
   /**
    * Contribute a text section to the system prompt. Order is determined by
-   * `section.order` (ascending). Throws if a section with the same name is
-   * already registered (a duplicate would silently double prompt text — e.g.
-   * a double-loaded tool plugin). The section is removed when the calling
-   * fiber is disposed. Emits `system-prompt/change` on register/unregister.
+   * `section.order` (ascending). The layer is decided by the CALLING context
+   * (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a
+   * scoped context (`agent.ctx`) contributes to that scope alone — and a
+   * scoped section SHADOWS a same-named global section for that scope's
+   * assemblies (most-specific-wins; this is how a per-agent persona overrides
+   * `deployment:persona`). Throws if the SAME layer already has the name (a
+   * duplicate would silently double prompt text — e.g. a double-loaded tool
+   * plugin; the global-duplicate message names `agent.ctx` as the per-agent
+   * alternative). Removed when the calling fiber is disposed. Emits
+   * `system-prompt/change` on register/unregister.
    * @param section - the section to contribute (name, order, text or provider).
    * @returns the disposer that removes the section.
    */
   section(section: PromptSection): () => void {
+    const scope = scopeOf(this.ctx)
     const dispose = this.ctx.effect(function* (this: SystemPrompt) {
-      if (this.sections.some(existing => existing.name === section.name)) {
-        throw new Error(`prompt section "${section.name}" is already registered`)
+      const layer = scope === undefined
+        ? this.sections
+        : this.scopedSections.get(scope) ?? (() => {
+          const created: PromptSection[] = []
+          this.scopedSections.set(scope, created)
+          return created
+        })()
+      if (layer.some(existing => existing.name === section.name)) {
+        throw new Error(scope === undefined
+          ? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
+          : `prompt section "${section.name}" is already registered in this scope`)
       }
-      this.sections.push(section)
+      layer.push(section)
       // Yield the rollback BEFORE emitting `system-prompt/change`: a generator
       // effect collects each yielded disposer before the next step runs, so a
       // throwing change listener removes the section instead of leaking it into
       // every future assembly.
       yield () => {
-        const index = this.sections.indexOf(section)
+        const index = layer.indexOf(section)
         /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
-        if (index >= 0) this.sections.splice(index, 1)
+        if (index >= 0) layer.splice(index, 1)
+        if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
         this.ctx.emit('system-prompt/change')
       }
       this.ctx.emit('system-prompt/change')
@@ -361,23 +426,36 @@ export class SystemPrompt extends Service {
   }
 
   /**
-   * Contribute a tool-schema provider that is evaluated at each assembly
-   * call (so it can reflect the live registry state). The provider is
-   * removed when the calling fiber is disposed. A provider must not return a
-   * schema named {@link TOOL_ORDER_REST}; that name is reserved for
+   * Contribute a tool-schema provider, evaluated at each assembly call with
+   * that assembly's {@link AssembleContext} (so it reflects the live registry
+   * state AND the assembly's scope — see {@link ToolProviderResult} for the
+   * `schemas`/`knownNames` split). The layer is decided by the calling
+   * context: a scoped provider (registered through `agent.ctx`) is consulted
+   * only for that scope's assemblies. Removed when the calling fiber is
+   * disposed. A provider must not return a schema named
+   * {@link TOOL_ORDER_REST}; that name is reserved for
    * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
    * `system-prompt/change`.
    * @param provider - evaluated at every {@link assemble} for fresh schemas.
    * @returns the disposer that removes the provider.
    */
-  tools(provider: () => ToolSchema[]): () => void {
+  tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
+    const scope = scopeOf(this.ctx)
     const dispose = this.ctx.effect(function* (this: SystemPrompt) {
-      this.toolProviders.push(provider)
+      const layer = scope === undefined
+        ? this.toolProviders
+        : this.scopedToolProviders.get(scope) ?? (() => {
+          const created: ((context: AssembleContext) => ToolProviderResult)[] = []
+          this.scopedToolProviders.set(scope, created)
+          return created
+        })()
+      layer.push(provider)
       // Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
       yield () => {
-        const index = this.toolProviders.indexOf(provider)
+        const index = layer.indexOf(provider)
         /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
-        if (index >= 0) this.toolProviders.splice(index, 1)
+        if (index >= 0) layer.splice(index, 1)
+        if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
         this.ctx.emit('system-prompt/change')
       }
       this.ctx.emit('system-prompt/change')
@@ -392,26 +470,40 @@ export class SystemPrompt extends Service {
    * `{{name}}`. The provider is evaluated at each assembly with that
    * assembly's {@link AssembleContext}; returning `undefined` means "no value
    * for this assembly" (a section referencing it then fails to render — a
-   * deployment must not claim facts it does not have). Throws on a name that
-   * does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is
-   * already registered. Removed when the calling fiber is disposed; emits
-   * `system-prompt/change` on register/unregister.
+   * deployment must not claim facts it does not have). The layer is decided
+   * by the calling context: a scoped variable (registered through
+   * `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
+   * same-named global variable there. Throws on a name that does not match
+   * `[a-z][a-z0-9_]*` (it could never be referenced) or one already
+   * registered in the SAME layer. Removed when the calling fiber is disposed;
+   * emits `system-prompt/change` on register/unregister.
    * @param name - the reference name (matches `[a-z][a-z0-9_]*`).
    * @param provider - evaluated at every {@link assemble} for the value.
    * @returns the disposer that removes the variable.
    */
   variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
+    const scope = scopeOf(this.ctx)
     const dispose = this.ctx.effect(function* (this: SystemPrompt) {
       if (!VARIABLE_NAME.test(name)) {
         throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
       }
-      if (this.variableProviders.has(name)) {
-        throw new Error(`prompt variable "${name}" is already registered`)
+      const layer = scope === undefined
+        ? this.variableProviders
+        : this.scopedVariableProviders.get(scope) ?? (() => {
+          const created = new Map<string, (context: AssembleContext) => string | undefined>()
+          this.scopedVariableProviders.set(scope, created)
+          return created
+        })()
+      if (layer.has(name)) {
+        throw new Error(scope === undefined
+          ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
+          : `prompt variable "${name}" is already registered in this scope`)
       }
-      this.variableProviders.set(name, provider)
+      layer.set(name, provider)
       // Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
       yield () => {
-        this.variableProviders.delete(name)
+        layer.delete(name)
+        if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
         this.ctx.emit('system-prompt/change')
       }
       this.ctx.emit('system-prompt/change')
@@ -422,13 +514,17 @@ export class SystemPrompt extends Service {
   }
 
   /**
-   * Assemble the current prompt for one caller: section texts are resolved
-   * against `context` and sorted by order, tools collected from all providers
-   * and put in the canonical model-facing order ({@link Config.toolOrder}, or
-   * lexicographic name order when unconfigured — provider registration order
-   * is a plugin-load artifact and never reaches the assembly; a configured
-   * order naming a tool no provider contributed rejects the assembly), and every
-   * registered variable resolved against `context` into `assembly.variables`.
+   * Assemble the current prompt for one caller: the global layer merged with
+   * {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
+   * same-named global ones — most-specific-wins) — section texts resolved
+   * against `context` and sorted by order across the union, tools collected
+   * from the global providers plus the scope's and put in the canonical
+   * model-facing order ({@link Config.toolOrder}, or lexicographic name order
+   * when unconfigured — provider registration order is a plugin-load artifact
+   * and never reaches the assembly; a configured order naming a tool outside
+   * the providers' `knownNames` universe rejects the assembly, while a known
+   * name restricted away for this scope is a normal absence), and every
+   * visible variable resolved against `context` into `assembly.variables`.
    * Tool schemas are deep-cloned because adapters and request waterfalls may
    * mutate schema objects. Runs through the `system-prompt/assemble`
    * waterfall, giving listeners the opportunity to mutate or replace the
@@ -445,25 +541,59 @@ export class SystemPrompt extends Service {
   // rejection: a Promise-returning method must not throw synchronously
   // (`assemble().catch(...)` would miss it).
   async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
+    const scope = context.scope
+    // Variables: global layer first, then the scope's layer OVERWRITES
+    // same-named entries (shadowing — a per-agent value wins for that agent).
     const variables: Record<string, string | undefined> = {}
     for (const [name, provider] of this.variableProviders) {
       variables[name] = provider(context)
     }
+    const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
+    for (const [name, provider] of scopedVariables ?? []) {
+      variables[name] = provider(context)
+    }
+    // Sections: merge by name, scoped REPLACING same-named global entries
+    // (most-specific-wins — the per-agent persona mechanism), then sort by
+    // order across the union. Registration order within a layer is preserved
+    // for equal orders (stable sort).
+    const sectionByName = new Map<string, PromptSection>()
+    for (const section of this.sections) sectionByName.set(section.name, section)
+    for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
+      sectionByName.set(section.name, section)
+    }
+    // Tools: consult the global providers plus the scope's, each with this
+    // assembly's context. `schemas` are what the model may see (already
+    // post-restriction, per provider); `knownNames` (defaulting to the
+    // schemas' names) form the pre-restriction universe `toolOrder` is
+    // validated against, so a restricted-away tool is a normal absence while
+    // a config typo still fails every assembly loudly.
+    const providers = [
+      ...this.toolProviders,
+      ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
+    ]
+    const collected: ToolSchema[] = []
+    const knownNames = new Set<string>()
+    for (const provider of providers) {
+      const result = provider(context)
+      for (const tool of result.schemas) {
+        collected.push({ ...tool, parameters: structuredClone(tool.parameters) })
+      }
+      for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) {
+        knownNames.add(name)
+      }
+    }
     const assembly: PromptAssembly = {
-      sections: this.sections
+      sections: [...sectionByName.values()]
         .map(section => ({
           name: section.name,
           order: section.order,
           text: typeof section.text === 'function' ? section.text(context) : section.text,
         }))
         .sort((a, b) => a.order - b.order),
-      tools: orderTools(
-        this.toolProviders.flatMap(provider =>
-          provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
-        this.toolOrder),
+      tools: orderTools(collected, this.toolOrder, knownNames),
       variables,
     }
-    return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
+    return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
   }
 }
 

+ 138 - 0
packages/core/system-prompt/tests/scoped.spec.ts

@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
+import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
+import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
+
+async function mount(config: Config = {}): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt, config)
+  return ctx
+}
+
+async function mintScope(ctx: Context, name: string): Promise<Scope> {
+  let scope!: Scope
+  // The scoped context resolves services through the MINTING plugin's
+  // dependency chain — the minter must inject what scope holders will reach.
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
+    { inject: ['systemPrompt'] }))
+  return scope
+}
+
+const schema = (name: string) => ({ name, description: `tool ${name}`, parameters: {} })
+
+/** The key a test scope was minted with (scopeOf over the scope's own ctx). */
+function scopeKeyOf(scope: Scope): ScopeKey {
+  // scopeOf never answers undefined for a context the scope itself minted.
+
+  return scopeOf(scope.ctx)!
+}
+
+describe('scoped sections', () => {
+  it('a scoped persona shadows deployment:persona for that scope only (either order)', async () => {
+    const ctx = await mount({ persona: 'You are the deployment.' })
+    const scope = await mintScope(ctx, 'child')
+    scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
+
+    const scoped = renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))
+    const global = renderPrompt(await ctx.systemPrompt.assemble())
+    expect(scoped).toContain('You run tests.')
+    expect(scoped).not.toContain('You are the deployment.')
+    expect(global).toContain('You are the deployment.')
+    expect(global).not.toContain('You run tests.')
+  })
+
+  it('scoped-only sections join that scope alone; disposal removes them', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    scope.ctx.systemPrompt.section({ name: 'child:extra', order: 50, text: 'Extra guidance.' })
+
+    expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Extra guidance.')
+    expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('Extra guidance.')
+    await scope.dispose()
+    expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).not.toContain('Extra guidance.')
+  })
+
+  it('duplicate names throw per layer, naming agent.ctx for the global case', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    ctx.systemPrompt.section({ name: 'x', order: 1, text: 'a' })
+    expect(() => ctx.systemPrompt.section({ name: 'x', order: 1, text: 'b' })).toThrow(/agent\.ctx/)
+    scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
+    expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
+  })
+})
+
+describe('scoped variables', () => {
+  it('a scoped variable shadows its global name-twin for that scope', async () => {
+    const ctx = await mount({ persona: 'Mode: {{mode}}.' })
+    const scope = await mintScope(ctx, 'child')
+    ctx.systemPrompt.variable('mode', () => 'normal')
+    scope.ctx.systemPrompt.variable('mode', () => 'strict')
+
+    expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Mode: strict.')
+    expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Mode: normal.')
+  })
+
+  it('same-layer duplicates throw; scoped layer cleans up on dispose', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    scope.ctx.systemPrompt.variable('v', () => '1')
+    expect(() => scope.ctx.systemPrompt.variable('v', () => '2')).toThrow(/already registered in this scope/)
+    await scope.dispose()
+    // Re-minting a scope with the SAME key starts clean.
+    const again = await mintScope(ctx, 'child2')
+    again.ctx.systemPrompt.variable('v', () => '3')
+  })
+})
+
+describe('scoped tool providers and toolOrder × restriction', () => {
+  it('scoped providers are consulted only for their scope', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    ctx.systemPrompt.tools(() => ({ schemas: [schema('global_tool')] }))
+    scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
+
+    const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
+    const global = await ctx.systemPrompt.assemble()
+    expect(scoped.tools.map(t => t.name)).toEqual(['global_tool', 'scoped_tool'])
+    expect(global.tools.map(t => t.name)).toEqual(['global_tool'])
+  })
+
+  it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => {
+    const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] })
+    // A provider mimicking the registry's restriction split: bash exists
+    // (knownNames) but is masked for this assembly (schemas).
+    ctx.systemPrompt.tools(() => ({
+      schemas: [schema('read')],
+      knownNames: ['read', 'bash'],
+    }))
+    const assembly = await ctx.systemPrompt.assemble()
+    expect(assembly.tools.map(t => t.name)).toEqual(['read'])
+
+    const bad = await mount({ toolOrder: ['basj', TOOL_ORDER_REST] })
+    bad.systemPrompt.tools(() => ({ schemas: [schema('read')], knownNames: ['read', 'bash'] }))
+    await expect(bad.systemPrompt.assemble()).rejects.toThrow('toolOrder lists unregistered tool "basj"; known tools: bash, read')
+  })
+})
+
+describe('scoped assemble dispatch', () => {
+  it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => {
+    const ctx = await mount()
+    const scope = await mintScope(ctx, 'child')
+    const shaped: (ScopeKey | undefined)[] = []
+    scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
+      shaped.push(context.scope)
+      const result = await next()
+      result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
+      return result
+    })
+
+    const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
+    const global = await ctx.systemPrompt.assemble()
+    expect(scoped.sections.some(s => s.name === 'listener:extra')).toBe(true)
+    expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
+    expect(shaped).toHaveLength(1)
+  })
+})

+ 8 - 8
packages/core/system-prompt/tests/system-prompt.spec.ts

@@ -52,7 +52,7 @@ describe('SystemPrompt', () => {
 
     ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
     ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
-    ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
+    ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
 
     const assembly = await ctx.systemPrompt.assemble()
     expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
@@ -84,7 +84,7 @@ describe('SystemPrompt', () => {
 
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
-      inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
+      inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
       inner.systemPrompt.variable('scoped_var', () => 'v')
     }, { inject: ['systemPrompt'] }))
 
@@ -141,11 +141,11 @@ describe('SystemPrompt', () => {
       if (!threw) { threw = true; throw new Error('boom change listener') }
     })
 
-    expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener')
+    expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener')
     expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
 
     off()
-    ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])
+    ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))
     expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
   })
 
@@ -209,7 +209,7 @@ describe('SystemPrompt', () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)
     ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
-    ctx.systemPrompt.tools(() => [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
+    ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] }))
 
     const first = await ctx.systemPrompt.assemble()
     first.sections[0]!.name = 'mutated'
@@ -243,7 +243,7 @@ describe('SystemPrompt', () => {
     let changeCount = 0
     ctx.on('system-prompt/change', () => void changeCount++)
 
-    const dispose = ctx.systemPrompt.tools(() => [])
+    const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] }))
     // registration emits change
     expect(changeCount).toBe(1)
 
@@ -257,7 +257,7 @@ describe('SystemPrompt', () => {
     await ctx.plugin(SystemPrompt)
 
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      inner.systemPrompt.tools(() => [{ name: 'fiber-tool', description: '', parameters: {} }])
+      inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] }))
     }, { inject: ['systemPrompt'] }))
 
     expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
@@ -280,7 +280,7 @@ describe('SystemPrompt', () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)
 
-    const dispose = ctx.systemPrompt.tools(() => [{ name: 'direct-tool', description: '', parameters: {} }])
+    const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
     expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
 
     dispose()

+ 13 - 13
packages/core/system-prompt/tests/tool-order.spec.ts

@@ -26,39 +26,39 @@ describe('SystemPrompt tool order', () => {
 
   it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
     const ctx = await mount()
-    ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
-    ctx.systemPrompt.tools(() => [tool('bravo')])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('charlie'), tool('alpha')] }))
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('bravo')] }))
     expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
   })
 
   it('assembles the same order regardless of provider registration order', async () => {
     const forward = await mount()
-    forward.systemPrompt.tools(() => [tool('alpha')])
-    forward.systemPrompt.tools(() => [tool('zulu')])
+    forward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
+    forward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
     const backward = await mount()
-    backward.systemPrompt.tools(() => [tool('zulu')])
-    backward.systemPrompt.tools(() => [tool('alpha')])
+    backward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
+    backward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
     expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
     expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
   })
 
   it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
     const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
-    ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')] }))
     expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
   })
 
   it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
     const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
-    ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))
     await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
-      'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
+      'toolOrder lists unregistered tools "ghost", "wraith"; known tools: bash, todo_write')
   })
 
   it('names the single unregistered tool when no tools are registered at all', async () => {
     const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
     await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
-      'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
+      'toolOrder lists unregistered tool "ghost"; known tools: (none)')
   })
 
   it.each([
@@ -66,21 +66,21 @@ describe('SystemPrompt tool order', () => {
     ['with only the rest entry configured', [TOOL_ORDER_REST]],
   ])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
     const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
-    ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool(TOOL_ORDER_REST)] }))
     await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
       `tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
   })
 
   it('keeps collection order between tools that share a name (stable sort)', async () => {
     const ctx = await mount()
-    ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')] }))
     const assembly = await ctx.systemPrompt.assemble()
     expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
   })
 
   it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
     const ctx = await mount()
-    ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
+    ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] }))
     let seen: string[] | undefined
     ctx.on('system-prompt/assemble', function (assembly, _context, next) {
       seen = assembly.tools.map(t => t.name)

+ 3 - 0
packages/core/system-prompt/tsconfig.json

@@ -19,6 +19,9 @@
     },
     {
       "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/scope"
     }
   ]
 }

+ 2 - 0
packages/core/tools/package.json

@@ -24,12 +24,14 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-scope": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }

+ 209 - 26
packages/core/tools/src/index.ts

@@ -9,6 +9,8 @@
  */
 
 import { Context, Service } from 'cordis'
+import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
+import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
 import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -70,10 +72,14 @@ declare module 'cordis' {
      * tool body never runs. Input rewrite is deliberately NOT offered here (see
      * {@link PreToolDecision}); `ask` degrades to deny until the permission
      * system lands (`FIXME(permissions)`).
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
+     * `exec.agent` — a listener registered through `agent.ctx` fires only for
+     * that agent's calls; a plain plugin listener fires for every call
+     * (including agent-less ones, which dispatch subject-less).
      * @param exec - the pending call (name, parsed arguments, caller agent).
      * @mode waterfall
      */
-    'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
+    'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
     /**
      * Waterfall AFTER a tool runs — where hook plugins inspect the result and
      * accept it (optionally REPLACING the model-facing content, and/or attaching
@@ -85,13 +91,22 @@ declare module 'cordis' {
      * `execute`'s outer try/catch (and the tool body keeps its own inner
      * try/catch, so a thrown tool still reaches `post-execute` as an `isError`
      * result).
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
+     * `exec.agent` — a listener registered through `agent.ctx` fires only for
+     * that agent's calls; a plain plugin listener fires for every call
+     * (including agent-less ones, which dispatch subject-less).
      * @param exec - the call that just ran (name, parsed arguments, caller agent).
      * @param result - the dispatch outcome a listener may accept, replace, or block.
      * @mode waterfall
      */
-    'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
+    'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
     /**
-     * A tool was registered or unregistered (the available tool set changed).
+     * A tool was registered or unregistered, or a scoped restriction changed
+     * (the available tool set changed — possibly for one scope only). An
+     * UNFILTERED registry-subject notification, deliberately not scope-filtered
+     * dispatch: a global change concerns every agent's next assembly, so a
+     * scoped listener subscribing here sees every change, not just its own
+     * scope's.
      * @mode emit
      */
     'tools/change'(): void
@@ -269,44 +284,88 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
   return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
 }
 
+/**
+ * A per-scope restriction over the GLOBAL tool surface, registered via
+ * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
+ * `deny` removes the listed ones; both present = allow first, then deny.
+ * Restrictions never touch scoped registrations — a tool registered through
+ * the same scope is an explicit grant that bypasses them (which is what keeps
+ * e.g. a structured-output capture tool alive under an allow-list). Multiple
+ * restrictions on one scope compose by intersection: every one must admit.
+ */
+export interface ToolRestriction {
+  /** Global tool names that stay visible; everything else is removed. */
+  allow?: string[]
+  /** Global tool names removed from visibility. */
+  deny?: string[]
+}
+
 /**
  * Tool registry (`ctx.tools`): tool plugins register definitions; the agent
  * loop executes calls through the `tools/pre-execute` → dispatch →
  * `tools/post-execute` pipeline. The registry contributes its schemas into the
  * system-prompt assembly.
+ *
+ * Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
+ * plain plugin context is GLOBAL (visible to every agent); one through a
+ * scoped context (`agent.ctx`) is filed in that scope's layer — visible to
+ * that agent alone, disposed with the scope, and SHADOWING a global tool of
+ * the same name for that agent (most-specific-wins; within one layer a
+ * duplicate name still throws). {@link restrict} masks the global layer per
+ * scope. One visibility function ({@link visible}) feeds prompt assembly,
+ * {@link get}, and {@link execute}, so what the model is shown, what a
+ * presenter renders, and what dispatches can never disagree.
  */
 export class ToolRegistry extends Service {
   static inject = ['systemPrompt']
 
-  private store = new Map<string, ToolDefinition>()
+  private global = new Map<string, ToolDefinition>()
+  private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
+  /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
+  private restrictions = new Map<ScopeKey, ToolRestriction[]>()
 
   constructor(ctx: Context) {
     super(ctx, 'tools')
-    ctx.systemPrompt.tools(() => this.schemas())
+    ctx.systemPrompt.tools(context => ({
+      schemas: this.schemas(context.scope),
+      knownNames: this.knownNames(context.scope),
+    }))
   }
 
   /**
-   * Register a tool. Throws if a tool with the same name is already
-   * registered. The tool's schema (minus the `execute` function) is
-   * automatically contributed to the system-prompt assembly. Disposed
-   * with the calling fiber. Emits `tools/change` on register/unregister.
+   * Register a tool. The layer is decided by the CALLING context: a plain
+   * plugin context registers globally; a scoped context (`agent.ctx`)
+   * registers into that scope's layer — visible to that agent alone, disposed
+   * with the scope, and shadowing a same-named global tool for that agent.
+   * Throws if the SAME layer already has the name (cross-layer name twins are
+   * the shadowing feature, not an error; the global-duplicate message names
+   * `agent.ctx` as the per-agent alternative). The visible schema set flows
+   * into prompt assembly automatically. Disposed with the calling fiber.
+   * Emits `tools/change` on register/unregister.
    * @param definition - the tool's schema plus its execute (and optional
    *   presentation) functions.
    * @returns the disposer that unregisters the tool.
    */
   register(definition: ToolDefinition): () => void {
+    const scope = scopeOf(this.ctx)
     const dispose = this.ctx.effect(function* (this: ToolRegistry) {
-      if (this.store.has(definition.name)) {
-        throw new Error(`tool "${definition.name}" is already registered`)
+      const layer = scope === undefined ? this.global : this.layerFor(scope)
+      if (layer.has(definition.name)) {
+        throw new Error(scope === undefined
+          ? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
+          : `tool "${definition.name}" is already registered in this scope`)
       }
-      this.store.set(definition.name, definition)
+      layer.set(definition.name, definition)
       // Yield the rollback BEFORE emitting `tools/change`: a generator effect
       // collects each yielded disposer before the next step runs, so a throwing
       // `tools/change` listener removes the tool instead of leaking it (a leak
       // would wedge the duplicate-name check until restart). The duplicate
       // throw above fires before any mutation — it leaks nothing.
       yield () => {
-        this.store.delete(definition.name)
+        layer.delete(definition.name)
+        // An emptied scope layer is dropped so a disposed scope leaves no
+        // residue keyed by its (dead) key.
+        if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
         this.ctx.emit('tools/change')
       }
       this.ctx.emit('tools/change')
@@ -317,33 +376,150 @@ export class ToolRegistry extends Service {
   }
 
   /**
-   * Look up a registered tool.
+   * Restrict the GLOBAL tool surface for the calling scope. Must be called
+   * through a scoped context (`agent.ctx`) — restricting "everyone" is not a
+   * thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
+   * that can only be a bug (throw — the materialized-empty-config trap).
+   * Validates every listed name against the scope's CURRENT pre-restriction
+   * name universe ({@link knownNames}) and throws on an unknown one (fail loud
+   * beats a typo silently filtering nothing) — register restrictions after the
+   * global tools they mask exist (the agent-creation `setup` window satisfies
+   * this). The filter is SNAPSHOT at registration: later caller mutation of
+   * the arrays changes nothing. Multiple restrictions compose by intersection.
+   * Scoped registrations bypass restrictions (explicit grants win). Disposed
+   * with the calling fiber (revocable independently); emits `tools/change`.
+   * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
+   * @returns the disposer that lifts this restriction.
+   */
+  restrict(filter: ToolRestriction): () => void {
+    const scope = scopeOf(this.ctx)
+    if (scope === undefined) {
+      throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
+    }
+    if (filter.allow === undefined && filter.deny === undefined) {
+      throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
+    }
+    // Snapshot BEFORE validation so what was checked is what is enforced.
+    const snapshot: ToolRestriction = {
+      ...filter.allow !== undefined ? { allow: [...filter.allow] } : {},
+      ...filter.deny !== undefined ? { deny: [...filter.deny] } : {},
+    }
+    const known = new Set(this.knownNames(scope))
+    const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name))
+    if (unknown.length > 0) {
+      throw new Error(`tools.restrict() names unknown tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known tools for this scope: ${[...known].sort().join(', ') || '(none)'}`)
+    }
+    const dispose = this.ctx.effect(function* (this: ToolRegistry) {
+      const list = this.restrictions.get(scope) ?? []
+      this.restrictions.set(scope, list)
+      list.push(snapshot)
+      yield () => {
+        const index = list.indexOf(snapshot)
+        /* v8 ignore next 3 -- defensive: the snapshot was pushed, so indexOf is guaranteed >= 0 */
+        if (index >= 0) list.splice(index, 1)
+        if (list.length === 0) this.restrictions.delete(scope)
+        this.ctx.emit('tools/change')
+      }
+      this.ctx.emit('tools/change')
+    }.bind(this), 'tools.restrict()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
+  }
+
+  /** The (created-on-demand) scoped layer for `scope`. */
+  private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
+    let layer = this.scoped.get(scope)
+    if (!layer) {
+      layer = new Map()
+      this.scoped.set(scope, layer)
+    }
+    return layer
+  }
+
+  /** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
+  private admits(scope: ScopeKey | undefined, name: string): boolean {
+    if (scope === undefined) return true
+    const filters = this.restrictions.get(scope)
+    if (!filters) return true
+    return filters.every(filter =>
+      (filter.allow === undefined || filter.allow.includes(name))
+      && (filter.deny === undefined || !filter.deny.includes(name)))
+  }
+
+  /**
+   * THE visibility function — one resolution feeding prompt assembly,
+   * {@link get}, and {@link execute}: the global layer masked by the scope's
+   * restrictions, unioned with the scope's own layer, scoped shadowing global
+   * on a name conflict. No scope = the unrestricted global view.
+   * @param scope - the viewing scope (the agent), or undefined for the global view.
+   * @returns the visible definitions (scoped shadows applied), in per-layer
+   *   registration order, global layer first.
+   */
+  visible(scope?: ScopeKey): ToolDefinition[] {
+    const layer = scope === undefined ? undefined : this.scoped.get(scope)
+    const result = new Map<string, ToolDefinition>()
+    for (const [name, definition] of this.global) {
+      if (this.admits(scope, name)) result.set(name, definition)
+    }
+    // Scoped layer second: same-name entries REPLACE (shadow) the global ones,
+    // and grants bypass restrictions by construction (never filtered above).
+    for (const [name, definition] of layer ?? []) result.set(name, definition)
+    return [...result.values()]
+  }
+
+  /**
+   * Look up a tool as one scope sees it ({@link visible} semantics: scoped
+   * shadows global; a restricted-away global reads as absent). Presenters pass
+   * the calling agent so the rendered card matches the definition that
+   * actually executed.
    * @param name - the tool name as registered.
-   * @returns the definition, or undefined when no tool has that name.
+   * @param scope - the viewing scope (the agent); omitted = the global view.
+   * @returns the definition the scope resolves, or undefined when none is visible.
    */
-  get(name: string): ToolDefinition | undefined {
-    return this.store.get(name)
+  get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
+    const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name)
+    if (shadowed) return shadowed
+    if (!this.admits(scope, name)) return undefined
+    return this.global.get(name)
   }
 
   /**
-   * Return all registered tool schemas — exactly the model-facing fields
-   * (`name`, `description`, `parameters`), as sent to the model via the
+   * The model-facing schemas of everything `scope` can see — exactly the
+   * fields (`name`, `description`, `parameters`) sent to the model via the
    * system-prompt assembly. Constructed EXPLICITLY rather than by stripping
    * known non-schema members: a `ToolDefinition` also carries `execute` and the
    * optional `presentCall`/`presentResult` UI callbacks, and those (especially
    * the functions) must never leak into a model request. An allowlist can't
    * drift when a new non-schema member is added to the definition; a denylist
    * (rest-destructure) would silently leak it.
-   * @returns one deep-cloned schema per registered tool, in registration order.
+   * @param scope - the viewing scope (the agent); omitted = the global view.
+   * @returns one deep-cloned schema per visible tool.
    */
-  schemas(): ToolSchema[] {
-    return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
+  schemas(scope?: ScopeKey): ToolSchema[] {
+    return this.visible(scope).map(({ name, description, parameters }): ToolSchema => ({
       name,
       description,
       parameters: structuredClone(parameters),
     }))
   }
 
+  /**
+   * The PRE-restriction name universe for `scope`: every global name plus the
+   * scope's own layer, ignoring restrictions. This is the set configuration
+   * (`toolOrder`, `restrict()` filters) validates against, so a typo fails
+   * loud while a restricted-away tool remains a normal, non-erroneous absence.
+   * @param scope - the viewing scope (the agent); omitted = global names only.
+   * @returns the known names, deduplicated.
+   */
+  knownNames(scope?: ScopeKey): string[] {
+    const names = new Set(this.global.keys())
+    if (scope !== undefined) {
+      for (const name of this.scoped.get(scope)?.keys() ?? []) names.add(name)
+    }
+    return [...names]
+  }
+
   /**
    * Execute one tool call through the `tools/pre-execute` → dispatch →
    * `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
@@ -362,9 +538,12 @@ export class ToolRegistry extends Service {
   async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
     try {
       // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
-      // until the permission system lands) skips dispatch entirely. ---
+      // until the permission system lands) skips dispatch entirely. The
+      // carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
+      // gates only its own agent's calls (agent-less calls are subject-less).
+      const carrier = scopeTarget(this, exec.agent)
       const decision = await this.ctx.waterfall(
-        this, 'tools/pre-execute', exec,
+        carrier, 'tools/pre-execute', exec,
         () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
       )
       if (decision.kind !== 'allow') {
@@ -387,7 +566,11 @@ export class ToolRegistry extends Service {
       // inspect it; an unknown tool routes through the same catch. ---
       let result: ToolExecutionResult
       try {
-        const tool = this.store.get(exec.name)
+        // Resolve through the CALLER's visible view ({@link get}): a scoped
+        // tool shadows its global name-twin for that agent, and a
+        // restricted-away global tool is exactly as absent as a nonexistent
+        // one — same UNKNOWN_TOOL result, no capability leak in the error.
+        const tool = this.get(exec.name, exec.agent)
         if (!tool) throw new ToolNotFoundError(exec.name)
         // Normalize the two `execute` return shapes: a bare ContentBlock[] (no
         // meta) or a { content, meta } object (a tool attaching a private
@@ -435,7 +618,7 @@ export class ToolRegistry extends Service {
       ...result.meta !== undefined ? { meta: result.meta } : {},
     }
     const decision = await this.ctx.waterfall(
-      this, 'tools/post-execute', exec, result,
+      scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
       () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
     )
     const additionalContext = decision.additionalContext

+ 172 - 0
packages/core/tools/tests/scoped.spec.ts

@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { createScope } from '@deepseek-ai/dsh-scope'
+import type { Scope } from '@deepseek-ai/dsh-scope'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
+import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+
+/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
+async function mount(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt, {})
+  await ctx.plugin(ToolRegistry)
+  return ctx
+}
+
+/** Mint a scope whose key doubles as a minimal Agent-like object. */
+async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
+  const key = { id: name as AgentId } as Agent
+  let scope!: Scope
+  // The scoped context resolves services through the MINTING plugin's
+  // dependency chain — the minter must inject what scope holders will reach
+  // (in production the agent loop's inject list plays this role).
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
+    { inject: ['tools', 'systemPrompt'] }))
+  return { scope, key }
+}
+
+function tool(name: string, reply = `ran:${name}`): ToolDefinition {
+  return {
+    name,
+    description: `tool ${name}`,
+    parameters: { type: 'object', properties: {} },
+    execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
+  }
+}
+
+async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
+  const result = await ctx.tools.execute({
+    callId: CallId('c1'),
+    name,
+    arguments: {},
+    ...agent ? { agent } : {},
+  })
+  const first = result.content[0]
+  return first?.type === 'text' ? first.text : JSON.stringify(result.content)
+}
+
+describe('scoped tool registration', () => {
+  it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    const other = { id: 'other' as AgentId } as Agent
+    ctx.tools.register(tool('shared'))
+    scope.ctx.tools.register(tool('mine'))
+
+    expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared'])
+    expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared'])
+    expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared'])
+
+    expect(await run(ctx, 'mine', key)).toBe('ran:mine')
+    // Out-of-view execution is indistinguishable from a nonexistent tool.
+    expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"')
+    expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"')
+  })
+
+  it('scoped shadows global on a name conflict, in either registration order', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    // scoped-then-global
+    scope.ctx.tools.register(tool('bash', 'restricted-bash'))
+    ctx.tools.register(tool('bash', 'global-bash'))
+    expect(await run(ctx, 'bash', key)).toBe('restricted-bash')
+    expect(await run(ctx, 'bash')).toBe('global-bash')
+    expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description)
+    // Exactly one 'bash' in the scope's schema view (the shadow, not a double).
+    expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1)
+  })
+
+  it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => {
+    const ctx = await mount()
+    const { scope } = await mintAgentScope(ctx, 'a')
+    ctx.tools.register(tool('x'))
+    expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/)
+    scope.ctx.tools.register(tool('y'))
+    expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
+  })
+
+  it('disposing the scope unwinds its registrations and leaves no residue', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    scope.ctx.tools.register(tool('mine'))
+    expect(ctx.tools.get('mine', key)).toBeDefined()
+    await scope.dispose()
+    expect(ctx.tools.get('mine', key)).toBeUndefined()
+    expect(ctx.tools.knownNames(key)).toEqual([])
+  })
+})
+
+describe('restrict()', () => {
+  it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    ctx.tools.register(tool('read'))
+    ctx.tools.register(tool('bash'))
+    scope.ctx.tools.register(tool('capture'))
+    scope.ctx.tools.restrict({ allow: ['read'] })
+
+    // The scoped grant survives the allow-list; the unlisted global is gone.
+    expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
+    expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
+    expect(await run(ctx, 'read', key)).toBe('ran:read')
+    expect(await run(ctx, 'capture', key)).toBe('ran:capture')
+    // Other scopes and the global view are untouched.
+    expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
+  })
+
+  it('composes multiple restrictions by intersection and lifts each independently', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name))
+    const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
+    scope.ctx.tools.restrict({ deny: ['b'] })
+    expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
+    liftAllow()
+    // The deny remains after the allow-list is lifted.
+    expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
+  })
+
+  it('snapshots the filter at registration (caller mutation changes nothing)', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    ctx.tools.register(tool('a'))
+    ctx.tools.register(tool('b'))
+    const filter = { deny: ['a'] }
+    scope.ctx.tools.restrict(filter)
+    filter.deny.push('b')
+    expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
+  })
+
+  it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
+    const ctx = await mount()
+    const { scope } = await mintAgentScope(ctx, 'a')
+    ctx.tools.register(tool('real'))
+    expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
+    expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
+    expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
+  })
+})
+
+describe('scoped execution dispatch', () => {
+  it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
+    const ctx = await mount()
+    const { scope, key } = await mintAgentScope(ctx, 'a')
+    const other = { id: 'other' as AgentId } as Agent
+    ctx.tools.register(tool('t'))
+
+    const seen: (string | undefined)[] = []
+    scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
+      seen.push(exec.agent?.id)
+      return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
+    })
+
+    expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
+    expect(await run(ctx, 't', other)).toBe('ran:t')
+    expect(await run(ctx, 't')).toBe('ran:t')
+    expect(seen).toEqual(['a'])
+  })
+})

+ 3 - 0
packages/core/tools/tsconfig.json

@@ -22,6 +22,9 @@
     },
     {
       "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/scope"
     }
   ]
 }

+ 15 - 0
pnpm-lock.yaml

@@ -183,6 +183,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../scope
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../session
@@ -245,6 +248,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../scope
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../session
@@ -278,6 +284,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../scope
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -291,6 +300,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../scope
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -303,6 +315,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../scope
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../system-prompt