Преглед изворни кода

feat(subagent): persona + toolFilter become real; structured runtime collapses to scoped registrations

SubagentStartRequest gains persona (capability-gated like toolFilter); the
in-process driver composes the child's scoped world in the factory's setup
window — persona as a scoped shadowing deployment:persona section,
toolFilter as a scoped tools.restrict() (loud unknown-name validation),
outputSchema as the scoped structured runtime. spawn/fork now advertise
every start-time capability; ACP stays all-false. A parent-scope teardown
effect links each child to its parent through the memoized handle, so a
disposed parent reaches its whole subtree even if the delegating tool's
finally never runs; subagent/start|end dispatch in the delegating parent's
scope.

structured.ts loses the placeholder schema, the final-assembly swap/strip,
the refcounted root runtime, and the WeakMap state: each child registers
its OWN capture tool (real schema), instruction section, and enforcement
listeners on child.ctx, riding the child's fiber. The commit listener is
call-keyed (a stale stage from a short-circuited post-execute chain is
dropped, never promoted on a later call), and one scoped prepend re-assert
listener preserves the final-assembly guarantee against a stripping global
listener.

tool-subagent gains persona/toolFilter/maxDepth passthrough config —
deny-listing the delegation tool (or maxDepth) is how a deployment bounds
recursion; the omitted-toolFilter schema key is forced absent (a
materialized {} would mean an empty allow-list, i.e. deny-everything).
Tianyi Cui пре 3 месеци
родитељ
комит
15f4d1cd03

+ 1 - 1
packages/subagent/subagent-acp/src/index.ts

@@ -89,7 +89,7 @@ type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  * a request needing any of them before `start` runs).
  */
 class AcpProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
   // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary.
   readonly inheritsParentContext = false
 

+ 1 - 1
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -530,7 +530,7 @@ describe('dsh-subagent-acp', () => {
   it('advertises no start-time capabilities (out-of-process child)', async () => {
     const ctx = await setup()
     const provider = ctx.subagents.getProvider('acp')!
-    expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false })
+    expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {

+ 3 - 3
packages/subagent/subagent-fork/src/index.ts

@@ -64,11 +64,11 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] {
 
 /**
  * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared
- * in-process structured runtime); NOT `toolFilter` this cut (the service
- * rejects a request needing it before `start` runs).
+ * in-process structured runtime), plus `toolFilter`/`persona` (scoped
+ * restrict() and a scoped shadowing persona section).
  */
 class ForkProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
   // Context contract: a forked child IS seeded with the parent's completed-turn prefix.
   readonly inheritsParentContext = true
 

+ 2 - 2
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -182,9 +182,9 @@ describe('dsh-subagent-fork', () => {
     await run.dispose()
   })
 
-  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
+  it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
     const { ctx } = await setup([])
-    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
+    expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {

+ 53 - 20
packages/subagent/subagent-inprocess/src/index.ts

@@ -21,13 +21,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
 import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
 import {
-  acquireStructuredRuntime,
-  type StructuredAcquisition,
+  attachStructuredRuntime,
+  type StructuredAttachment,
 } from './structured.ts'
 
-// The runtime itself (acquire/attach/release) is package-internal: runs
-// acquire it inside startInProcessRun, and no other package drives it. Only
-// the model-facing vocabulary is public.
+// The runtime itself (attach) is package-internal: runs attach it inside
+// startInProcessRun's setup window, and no other package drives it. Only the
+// model-facing vocabulary is public.
 export {
   STRUCTURED_OUTPUT_TOOL,
   STRUCTURED_OUTPUT_INSTRUCTION,
@@ -143,21 +143,36 @@ export function startInProcessRun(
   const seedLength = options.seed?.length ?? 0
   const parentHeader = request.parent.session.header
   // Inherit the parent's model by default (a child with no model cannot run);
-  // an explicit `request.agentOptions.model` overrides it. The persona needs
-  // no inheritance: the deployment persona is a context-wide prompt section,
-  // so parent and child render the same one. A structured run's
-  // structured_output instruction is NOT prompt state either — the structured
-  // runtime's final-request listener appends it per request (see structured.ts).
+  // an explicit `request.agentOptions.model` overrides it. The deployment
+  // persona needs no inheritance (a context-wide section both render); a
+  // per-child `request.persona` becomes a SCOPED section of the same name in
+  // the setup below, shadowing the deployment's for this child alone.
   const agentOptions: AgentOptions = {
     ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
     ...request.agentOptions,
     subagentDepth: childDepth,
   }
 
-  // The structured runtime is held for the WHOLE run (acquired before the child
-  // exists, released when the result settles), so a backend hot-reload mid-run
-  // cannot unregister the capture tool out from under this live child.
-  const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined
+  // The child's scoped world, composed in the factory's setup window (after
+  // the child's scope exists and it is registered, before agent/session-start
+  // and the first prompt assembly; a throw here unwinds the half-created
+  // child inside the factory's rollback boundary):
+  //  - persona: a scoped `deployment:persona` section shadowing the global one;
+  //  - toolFilter: a scoped restrict() masking the global tool surface
+  //    (loud unknown-name validation lives in the registry);
+  //  - outputSchema: the structured runtime, attached as scoped registrations.
+  let structured: StructuredAttachment | undefined
+  const setup = (childCtx: Context): void => {
+    if (request.persona !== undefined) {
+      childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
+    }
+    if (request.toolFilter !== undefined) {
+      childCtx.tools.restrict(request.toolFilter)
+    }
+    if (schema !== undefined) {
+      structured = attachStructuredRuntime(childCtx, schema)
+    }
+  }
 
   const handle: AgentHandle = ctx.agents.create({
     agentId: childId,
@@ -171,9 +186,26 @@ export function startInProcessRun(
     },
     ...options.seed !== undefined ? { seed: options.seed } : {},
     agentOptions,
+    setup,
   })
   const child = handle.agent
-  if (structured && schema !== undefined) structured.attach(child, schema)
+
+  // Structured-concurrency link: the child's teardown rides the PARENT's
+  // scope, so a disposed parent reaches its whole subtree even if the
+  // delegating tool's `finally` never runs — through the MEMOIZED handle, so
+  // every path (tool finally, parent teardown, owner unload) observes the
+  // same quiescence boundary. Registered AFTER the child exists; if the
+  // parent began disposing in between, the registration throws
+  // INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan).
+  // Definite assignment: the catch rethrows, so past this block the unlink
+  // disposer always exists.
+  let unlink!: () => Promise<void> | void
+  try {
+    unlink = request.parent.ctx.effect(() => () => handle.dispose())
+  } catch (error: unknown) {
+    void handle.dispose()
+    throw error
+  }
 
   // Bridge the request's abort signal to the child (the consumer also bridges
   // its own exec.signal, but a backend-level bridge keeps the contract local).
@@ -205,13 +237,9 @@ export function startInProcessRun(
       // Deliberately NO re-prompt when a structured child finishes cleanly
       // without calling structured_output: readResult maps that to `error` —
       // the shortfall goes to the parent instead of buying extra model turns.
-      return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
+      return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
     } finally {
       request.signal?.removeEventListener('abort', onAbort)
-      if (structured) {
-        structured.detach(child)
-        structured.release()
-      }
     }
   })()
 
@@ -223,6 +251,11 @@ export function startInProcessRun(
     },
     async dispose(): Promise<void> {
       request.signal?.removeEventListener('abort', onAbort)
+      // Through the parent-scope unlink when the parent is still live (one
+      // disposal path, and the dead effect leaves the parent's list); the
+      // memoized handle keeps a direct dispose equivalent if the parent's
+      // teardown already ran the unlink.
+      await unlink()
       await handle.dispose()
     },
   }

+ 137 - 257
packages/subagent/subagent-inprocess/src/structured.ts

@@ -1,63 +1,49 @@
 /**
- * Structured-output support for the in-process subagent backends: the mechanism
- * behind `SubagentStartRequest.outputSchema` for children that run as agents on
- * the same context.
+ * Structured-output support for the in-process subagent backends: the
+ * mechanism behind `SubagentStartRequest.outputSchema` for children that run
+ * as agents on the same context.
  *
- * The model-facing surface is one globally registered `structured_output` tool
- * whose REGISTERED parameters are a placeholder — the real schema is per run.
- * Because the tool registry and prompt assembly are context-global while
- * schemas differ per child (two concurrent structured runs may carry different
- * schemas), per-agent shaping happens on the `system-prompt/assemble`
- * waterfall with a `prepend: true` listener that post-processes `await next()`
- * — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
- * replaced, the assembly the loop renders never carries `structured_output`
- * for an agent without a structured run, and for one that has it always
- * carries the run's OWN schema plus a trailing
- * {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
- * tool). The loop logs what the assembly produced as the request header, so
- * the injection is a reconstructable fact of the session log, never a
- * wire-only mutation (the reconstructability RFC).
- * (Cooperative mutate-then-`next()` would not survive a downstream listener
- * returning a replacement assembly — see the waterfall composition caveat in
- * docs/architecture.md.)
+ * Everything is a SCOPED registration on the child agent's context
+ * (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
+ * carries the run's REAL schema as its registered parameters (each child sees
+ * exactly its own schema — two concurrent structured runs never interact), the
+ * demand instruction is an ordinary order-190 scoped section, and the
+ * enforcement listeners fire only for this child (scope-filtered dispatch).
+ * Registration lifetime rides the child's fiber, so a backend hot-reload
+ * mid-run cannot unregister the capture tool out from under a live child, and
+ * a disposed child leaves no residue — no placeholder schema, no
+ * strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
  *
- * FIXME: the whole enforcement dance above exists because the tool registry
- * and prompt assembly are context-global. If they become per-agent or
- * per-session scoped, a structured run just registers its own schema'd tool on
- * the child's scope and this module reduces to the capture tool plus the
- * turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
- * else, no global-registration lifetime dance.
+ * Four listeners enforce the contract:
  *
- * A companion `agent/turn-continuation` listener stops a child's turn once its
- * output is captured — without it, the loop's default "had tool calls ⇒
- * continue" buys a wasted extra model step per structured child. It is also
- * `prepend: true`: the veto must run before any earlier-registered listener
- * that could short-circuit the chain into a forced continue. A third listener
- * closes the within-step window the continuation veto cannot: a
- * `tools/pre-execute` deny for any call arriving after the agent's capture, so
- * a response that lists `structured_output` before further tool calls cannot
- * run side effects after the final answer was accepted. A fourth,
- * `tools/post-execute`, is the capture COMMIT: the tool body only stages the
- * validated value, and it becomes the run's captured result only when the
- * final post-execute decision accepts the call — a blocking hook downstream
- * yields `isError` in the log, and the run must not report success for it.
- *
- * Lifetime is refcounted by structured RUNS: each acquires from start to
- * settle, so the registrations exist exactly while at least one structured
- * child is live — a plain deployment that never passes `outputSchema` carries
- * no always-on global state, and a backend hot-reload mid-run cannot
- * unregister the capture tool out from under a live child (the run holds its
- * own acquisition). Registrations land on the ROOT context and the refcount
- * disposes them when the last run settles; the next structured run
- * re-registers them.
+ * - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
+ *   whatever downstream listeners mutated or replaced, the child's assembly
+ *   always carries its capture tool and the trailing instruction section. The
+ *   registry already contributes both; this outermost wrapper preserves the
+ *   guarantee against a (global) listener that strips or replaces the
+ *   assembly. The loop logs the rendered assembly as the request header, so
+ *   the demand is reconstructable log state, never a wire-only mutation.
+ * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
+ *   its output is captured — the loop's default "had tool calls ⇒ continue"
+ *   would buy a wasted extra model step per structured child.
+ * - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
+ *   step — deny every call arriving after the capture, so a response that
+ *   lists `structured_output` before further tool calls cannot run side
+ *   effects after the final answer was accepted.
+ * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
+ *   only STAGES the validated value, KEYED BY CALL ID; it becomes the run's
+ *   captured result only when the final post-execute decision accepts THAT
+ *   call. Call-keyed staging closes a stale-stage hole: an outer
+ *   short-circuiting post-execute listener can orphan a staged value, and an
+ *   un-keyed commit would then promote it on a LATER call's acceptance —
+ *   reporting success for a value the model saw fail.
  *
  * @module @deepseek-ai/dsh-subagent-inprocess/structured
  */
 
 import type { Context } from 'cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
-import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
 import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
 import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
 import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
@@ -66,247 +52,141 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
 export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
 
 /**
- * The instruction the assembly listener appends to a structured child's
- * system prompt as a trailing section on every assembly. Per-assembly state,
- * NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
- * deployment config on the system-prompt plugin), so the same final-assembly
- * enforcement that injects the schema'd tool carries the instruction that
- * demands calling it.
+ * The instruction registered as the child's trailing (order-190, the end of
+ * the tool-guidance band) scoped prompt section: the demand travels with the
+ * tool, as ordinary prompt state of exactly one agent.
  */
 export const STRUCTURED_OUTPUT_INSTRUCTION
   = 'When you have your final answer, you MUST report it by calling the '
     + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
     + 'Do not finish with a plain text answer: only the tool call counts as your result.'
 
-/** One structured run's state: the schema to enforce and the captured value, once recorded. */
-interface RunState {
-  readonly schema: StructuredOutputSchema
+/** One structured run's live handle: read the captured value once the child settles. */
+export interface StructuredAttachment {
   /**
-   * A validated value awaiting the post-execute verdict on ITS OWN call. Set
-   * by the capture tool's body, promoted to {@link RunState.captured} only
-   * when the final `tools/post-execute` decision accepts the call — a
-   * downstream block turns the logged result into `isError`, and a value
-   * committed at body time would let the run report success for a call the
-   * model saw fail.
+   * The captured value, once the child called the tool with valid arguments
+   * and the final post-execute decision accepted that call.
+   * @returns the committed value, or undefined while none was accepted.
    */
-  pending?: { value: unknown }
-  captured?: { value: unknown }
-}
-
-/** The per-root-context runtime: run states plus the shared registrations. */
-interface StructuredRuntime {
-  refs: number
-  readonly states: WeakMap<Agent, RunState>
-  readonly disposers: (() => void)[]
+  captured(): { value: unknown } | undefined
 }
 
-/** One root context ⇒ one runtime (multi-app test isolation). */
-const runtimes = new WeakMap<Context, StructuredRuntime>()
-
 /**
- * One holder's handle on the shared structured runtime. `release()` is
- * idempotent per acquisition; the runtime's registrations are disposed when the
- * LAST holder (backend plugin or live run) releases.
+ * Attach the structured-output runtime to a child for `schema`: register the
+ * scoped capture tool (real schema), the scoped instruction section, and the
+ * four scoped enforcement listeners (see the module doc). Call from the
+ * agent-creation `setup` window with the child's scope context — every
+ * registration rides the child's fiber and unwinds with the child.
+ * @param childCtx - the child agent's scope context (`setup`'s argument).
+ * @param schema - the isolation-cloned, already-asserted schema subset to
+ *   enforce (see `assertSupportedOutputSchema` in dsh-tools).
+ * @returns the attachment handle (read `captured()` after the child settles).
  */
-export interface StructuredAcquisition {
-  /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */
-  attach(agent: Agent, schema: StructuredOutputSchema): void
-  /** The captured value, once the child called the tool with valid arguments. */
-  captured(agent: Agent): { value: unknown } | undefined
-  /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */
-  detach(agent: Agent): void
-  /** Drop this holder's reference (idempotent); the last release unregisters everything. */
-  release(): void
-}
+export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
+  /** A validated value staged by the capture tool body, awaiting ITS OWN call's post-execute verdict. */
+  let pending: { callId: CallId; value: unknown } | undefined
+  let captured: { value: unknown } | undefined
 
-/**
- * Acquire the per-root-context structured runtime, registering the capture tool
- * and the runtime's listeners on the FIRST acquisition. See the module doc
- * for the enforcement and lifetime design.
- * @param ctx - any context of the app; the runtime keys off `ctx.root`.
- * @returns this holder's handle (attach/captured/detach + idempotent release).
- */
-export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition {
-  const root: Context = ctx.root
-  let runtime = runtimes.get(root)
-  if (!runtime) {
-    runtime = { refs: 0, states: new WeakMap(), disposers: [] }
-    runtimes.set(root, runtime)
-    registerRuntime(root, runtime)
+  const schemaEntry: ToolSchema = {
+    name: STRUCTURED_OUTPUT_TOOL,
+    description:
+      'Report your final structured result. Call this exactly once, when your answer is complete; '
+      + 'the arguments must match this tool\'s parameter schema exactly.',
+    // ToolSchema.parameters is the wire-level JSON Schema object; the
+    // asserted subset type is structurally exactly that.
+    parameters: schema as unknown as Record<string, unknown>,
   }
-  runtime.refs += 1
 
-  let released = false
-  return {
-    attach(agent: Agent, schema: StructuredOutputSchema): void {
-      runtime.states.set(agent, { schema })
-    },
-    captured(agent: Agent): { value: unknown } | undefined {
-      return runtime.states.get(agent)?.captured
-    },
-    detach(agent: Agent): void {
-      runtime.states.delete(agent)
+  childCtx.tools.register({
+    ...schemaEntry,
+    execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
+      const violations = validateStructuredValue(schema, args)
+      // ToolArgsError → isError result with INVALID_ARGS: the model retries
+      // within the same turn, exactly like a schema-validated defineTool call.
+      if (violations.length > 0) throw new ToolArgsError(violations)
+      // Two-phase commit, KEYED BY THIS CALL: the body only stages; the
+      // post-execute listener promotes exactly this call's entry when the
+      // final decision accepts it.
+      pending = { callId: exec.callId, value: args }
+      return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
     },
-    release(): void {
-      if (released) return
-      released = true
-      runtime.refs -= 1
-      if (runtime.refs > 0) return
-      runtimes.delete(root)
-      for (const dispose of runtime.disposers.splice(0)) dispose()
-    },
-  }
-}
-
-/** Register the capture tool + the two listeners on the root context (first acquire). */
-function registerRuntime(root: Context, runtime: StructuredRuntime): void {
-  // The registered parameters are a PLACEHOLDER: the request listener below
-  // swaps in the run's real schema per child, and strips the tool entirely for
-  // every agent without a structured run — so this shape is never model-visible.
-  //
-  // Registration does NOT ride on the acquiring backend's plugin-level
-  // `inject`: a backend that waited on `tools` would apply later than it did
-  // before this module existed, shifting when its PROVIDER registers — and the
-  // delegation tool mirrors provider lifecycle, so that shift would reorder
-  // the model-visible tool list of every existing prompt. Instead the capture
-  // tool registers synchronously when `tools` is already live (the common
-  // case), and through a scoped inject fiber when the Loader happens to start
-  // the backend first. Either way the registration lands on root and is
-  // disposed by the runtime's refcount; disposing the fiber also covers the
-  // never-activated case.
-  let disposeTool: (() => void) | undefined
-  const registerCapture = (tools: Context['tools']): void => {
-    disposeTool = tools.register({
-      name: STRUCTURED_OUTPUT_TOOL,
-      description:
-        'Report your final structured result. Call this exactly once, when your answer is complete; '
-        + 'the arguments must match this tool\'s parameter schema exactly.',
-      parameters: { type: 'object', properties: {} },
-      execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
-        const state = exec.agent ? runtime.states.get(exec.agent) : undefined
-        if (!state) {
-          // Reachable only if a non-structured agent somehow calls the tool (the
-          // request listener strips it, so the model never sees it) — fail loud
-          // rather than capture into nowhere.
-          throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
-        }
-        const violations = validateStructuredValue(state.schema, args)
-        // ToolArgsError → isError result with INVALID_ARGS: the model retries
-        // within the same turn, exactly like a schema-validated defineTool call.
-        if (violations.length > 0) throw new ToolArgsError(violations)
-        // Two-phase commit: the body only STAGES the value; the post-execute
-        // listener below promotes it once the final decision accepts the call.
-        state.pending = { value: args }
-        return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
-      },
-    })
-  }
-  const liveTools = root.get('tools')
-  const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
-    registerCapture(childCtx.root.tools)
   })
-  if (liveTools) registerCapture(liveTools)
-  runtime.disposers.push(() => {
-    disposeTool?.()
-    void toolsFiber?.dispose()
+
+  childCtx.systemPrompt.section({
+    name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
+    order: 190,
+    text: STRUCTURED_OUTPUT_INSTRUCTION,
   })
 
-  // FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
-  // wrapper): post-process whatever the downstream listeners and the registry
-  // produced, so a downstream listener returning a replacement assembly cannot
-  // leak the tool to other agents or erase the child's schema. The loop logs
-  // the rendered assembly as the step's request header, so the swap is
-  // reconstructable log state, never a wire-only mutation.
-  runtime.disposers.push(root.on('system-prompt/assemble', async function (
-    this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
+  // FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this
+  // fires only for the child's assemblies; `await next()` returns whatever the
+  // downstream chain (and any replacement assembly) produced, and the capture
+  // tool + instruction are re-asserted onto it if anything stripped them.
+  childCtx.on('system-prompt/assemble', async function (
+    this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
   ): Promise<PromptAssembly> {
     const final = await next()
-    const state = context.agent ? runtime.states.get(context.agent) : undefined
-    if (state) {
-      const schemaEntry: ToolSchema = {
-        name: STRUCTURED_OUTPUT_TOOL,
-        description:
-          'Report your final structured result. Call this exactly once, when your answer is complete; '
-          + 'the arguments must match this tool\'s parameter schema exactly.',
-        // ToolSchema.parameters is the wire-level JSON Schema object; the
-        // asserted subset type is structurally exactly that.
-        parameters: state.schema as unknown as Record<string, unknown>,
-      }
-      final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
-      // The demand travels WITH the tool: a trailing section in the
-      // tool-guidance order band, appended after next() so it renders last
-      // (renderPrompt joins in array order).
+    if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
+      final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }]
+    }
+    if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) {
       final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
-      return final
     }
-    // No structured run: strip the placeholder so it is never model-visible.
-    // An empty tools array canonicalizes to an absent header/wire field
-    // (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
-    final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
     return final
-  }, { prepend: true }))
+  }, { prepend: true })
 
-  // Stop a structured child's turn once its output is captured: the default
-  // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
-  // after every successful capture. `prepend: true` puts the veto OUTERMOST —
-  // an earlier-registered listener that short-circuits the chain (a goal-style
-  // force-continue returning without `next()`) would otherwise decide the turn
-  // before this listener ever ran, and no downstream decision may resurrect a
-  // structured turn that is already finished.
-  runtime.disposers.push(root.on('agent/turn-continuation', function (
-    this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
+  // Stop the child's turn once its output is captured. `prepend: true` puts
+  // the veto OUTERMOST — an earlier-registered listener that short-circuits
+  // the chain (a goal-style force-continue returning without `next()`) would
+  // otherwise decide the turn before this listener ever ran, and no
+  // downstream decision may resurrect a structured turn that is finished.
+  childCtx.on('agent/turn-continuation', function (
+    this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
   ): Promise<ContinuationDecision> {
-    if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
+    if (captured) return Promise.resolve({ action: 'stop' })
+    return next()
+  }, { prepend: true })
+
+  // Terminal WITHIN the step: deny every call after the capture. Calls that
+  // PRECEDE the capture in the same response ran before `captured` was set
+  // and are untouched; a second `structured_output` is denied like any other.
+  childCtx.on('tools/pre-execute', function (
+    this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
+  ): Promise<PreToolDecision> {
+    if (captured) {
+      return Promise.resolve({
+        kind: 'deny',
+        reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
+      })
+    }
     return next()
-  }, { prepend: true }))
+  }, { prepend: true })
 
   // The capture COMMIT: promote the staged value only when the final
-  // post-execute decision accepts the call. The capture tool's body cannot
-  // decide — `tools/post-execute` runs after it, and a blocking listener (a
-  // PostToolUse hook) turns the logged result into `isError` feedback; a value
-  // committed at body time would make readResult report `structured` success
-  // for a call whose result the model and session log saw fail. `prepend:
-  // true` = outermost at registration time, so `await next()` returns the
-  // COMPOSED downstream decision — the same final verdict the registry maps
-  // onto the result. (A later-registered outer listener that blocks without
-  // delegating skips this commit entirely: the staged value is dropped and the
-  // run errors — failure-safe in the same direction.) The staging slot clears
-  // on every path, including a rejecting downstream listener.
-  runtime.disposers.push(root.on('tools/post-execute', async function (
+  // post-execute decision accepts THE SAME CALL that staged it. The staging
+  // slot clears on every path for that call; a stale entry from an outer
+  // short-circuited chain (its verdict never reached us) is dropped when any
+  // later call reaches the commit, never promoted.
+  childCtx.on('tools/post-execute', async function (
     this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
   ): Promise<PostToolDecision> {
-    const state = exec.agent ? runtime.states.get(exec.agent) : undefined
-    if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next()
-    const pending = state.pending
+    if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next()
+    if (pending.callId !== exec.callId) {
+      // A stale stage from a different call: an outer listener short-circuited
+      // that call's post-execute chain past this commit, so its verdict never
+      // reached us and the value must never be promoted — drop it.
+      pending = undefined
+      return next()
+    }
+    const staged = pending
     try {
       const decision = await next()
-      if (decision.kind === 'accept') state.captured = pending
+      if (decision.kind === 'accept') captured = { value: staged.value }
       return decision
     } finally {
-      delete state.pending
+      if (pending === staged) pending = undefined
     }
-  }, { prepend: true }))
+  }, { prepend: true })
 
-  // Terminal means terminal WITHIN the step, not only at its end: the
-  // turn-continuation veto above runs after every call in the current model
-  // response has executed, so a response that puts `structured_output` before
-  // further tool calls would still perform those side effects after the final
-  // answer was accepted. Deny every later call for a captured agent at the
-  // allow/deny gate — dispatch is skipped and the model sees an `isError`
-  // result naming the contract. Calls that PRECEDE the capture in the same
-  // response ran before `captured` was set and are untouched; a second
-  // `structured_output` is denied like any other call. `prepend: true` for the
-  // same reason as the continuation veto: no earlier-registered allow may
-  // short-circuit past the terminal contract.
-  runtime.disposers.push(root.on('tools/pre-execute', function (
-    this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
-  ): Promise<PreToolDecision> {
-    if (exec.agent && runtime.states.get(exec.agent)?.captured) {
-      return Promise.resolve({
-        kind: 'deny',
-        reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
-      })
-    }
-    return next()
-  }, { prepend: true }))
+  return { captured: () => captured }
 }

+ 46 - 160
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -5,7 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
-import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -13,7 +13,6 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import { startInProcessRun } from '../src/index.ts'
 import {
-  acquireStructuredRuntime,
   STRUCTURED_OUTPUT_INSTRUCTION,
   STRUCTURED_OUTPUT_TOOL,
 } from '../src/structured.ts'
@@ -47,7 +46,7 @@ async function setup(script: Script) {
   await ctx.plugin(SubagentService)
   const disposeProvider = ctx.subagents.registerProvider({
     name: 'spawn',
-    capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
+    capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
     inheritsParentContext: false,
     start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
   })
@@ -175,31 +174,20 @@ describe('in-process structured output', () => {
   })
 
   it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SystemPrompt)
-    await ctx.plugin(ToolRegistry)
-    // Registered BEFORE the structured runtime exists — without prepend, this
-    // goal-style listener would decide the turn first (returning WITHOUT
-    // calling next()) and the veto would never run.
+    // A goal-style listener registered BEFORE the child exists, returning a
+    // forced continue WITHOUT calling next(). Without prepend on the scoped
+    // veto, this would decide the turn first and buy a wasted model step —
+    // the one-response script would then throw on the second request.
+    const { ctx, parent, adapter } = await setup([
+      toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
+    ])
     ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
-    const acquisition = acquireStructuredRuntime(ctx)
-    const agent = { id: AgentId('structured-child') } as unknown as Agent
-    acquisition.attach(agent, SCHEMA)
-    const captured = await ctx.tools.execute({
-      callId: 'call-1' as never,
-      name: STRUCTURED_OUTPUT_TOOL,
-      arguments: { answer: 1 },
-      agent,
-    })
-    expect(captured.isError).toBeFalsy()
-    const decision = await ctx.waterfall(
-      'agent/turn-continuation', agent, 1,
-      { action: 'continue' },
-      () => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
-    )
-    expect(decision).toEqual({ action: 'stop' })
-    acquisition.detach(agent)
-    acquisition.release()
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const result = await run.result
+    expect(result.structured).toEqual({ answer: 7 })
+    expect(result.stopReason).toBe('completed')
+    expect(adapter.requests).toHaveLength(1)
+    await run.dispose()
   })
 
   it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
@@ -357,20 +345,14 @@ describe('in-process structured output', () => {
     await run.dispose()
   })
 
-  describe('final-request enforcement (the prepend agent/request listener)', () => {
-    it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
-      // Run-scoped acquisition means a plain deployment never registers the
-      // tool at all; the strip branch exists for the CONCURRENT case — a plain
-      // agent taking a turn while some structured child holds the runtime open.
+  describe('scoped registration (each child owns its capture tool)', () => {
+    it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
       const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
-      const hold = acquireStructuredRuntime(ctx)
       parent.send([{ type: 'text', text: 'hello' }])
       await parent.whenIdle()
-      // The placeholder IS in the registry during this turn; the assembly the
-      // loop rendered must not carry it for an agent without a structured run.
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
+      // Scoped registration: the global view has no capture tool, ever.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
       expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
-      hold.release()
     })
 
     it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
@@ -430,16 +412,21 @@ describe('in-process structured output', () => {
       await runB.dispose()
     })
 
-    it('wins against a downstream listener that REPLACES the assembly object', async () => {
+    it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
       const { ctx, parent, adapter } = await setup([
         toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
       ])
-      // A downstream (non-prepend) listener that returns a brand-new assembly —
-      // the composition caveat that erases cooperative mutations. Registered
-      // AFTER the runtime's prepend listener, so it runs INSIDE it.
+      // A global (every-assembly) listener that returns a brand-new assembly
+      // WITHOUT the capture tool or instruction — the composition caveat that
+      // erases cooperative mutations. The child's prepend re-assert runs
+      // OUTERMOST and restores both.
       ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
         const replaced = await next()
-        return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
+        return {
+          sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
+          tools: replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL),
+          variables: { ...replaced.variables },
+        }
       })
       const run = ctx.subagents.start('spawn', structuredRequest(parent))
       const result = await run.result
@@ -447,140 +434,41 @@ describe('in-process structured output', () => {
       const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
       expect(entry).toBeDefined()
       expect(entry!.parameters).toEqual(SCHEMA)
+      const system = adapter.requests[0]!.system ?? ''
+      expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
       await run.dispose()
     })
 
     it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
-      const { parent, adapter } = await setup([
-        // The registry contributes the placeholder via prompt assembly, so
-        // tools is an array in the raw request — but after stripping the
-        // placeholder (its ONLY entry), the field must not be re-added as a
-        // different shape.
-        textResponse('plain'),
-      ])
+      const { parent, adapter } = await setup([textResponse('plain')])
       parent.send([{ type: 'text', text: 'q' }])
       await parent.whenIdle()
       const request = adapter.requests[0]!
-      expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
+      expect(request.tools).toBeUndefined()
       await new Promise(resolve => setTimeout(resolve, 0))
     })
 
-    it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
-      // Drive ctx.systemPrompt.assemble directly — the enforcement listener
-      // must tolerate a context with NO agent (a bare diagnostic assemble)
-      // and shape a structured agent's assembly on the same path the loop
-      // renders and logs as the request header.
-      const { ctx, parent } = await setup([])
-      const acquisition = acquireStructuredRuntime(ctx)
-      // Bare assemble WHILE the runtime is live: the no-agent branch must
-      // strip the registered placeholder (before the acquisition there is
-      // nothing to strip — run-scoped registration).
-      const bare = await ctx.systemPrompt.assemble({})
-      expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
-
-      acquisition.attach(parent, SCHEMA)
-      const shaped = await ctx.systemPrompt.assemble({ agent: parent })
-      expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
-      expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
-      // The demand travels with the tool: the instruction renders LAST
-      // (appended post-next(); renderPrompt joins in array order).
-      expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
-      acquisition.detach(parent)
-      acquisition.release()
-    })
-  })
-
-  describe('runtime lifetime (refcount: live structured runs)', () => {
-    it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
-      const { ctx, parent } = await setup([
+    it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
+      const { ctx, parent, disposeProvider } = await setup([
         toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
       ])
-      // No always-on global state: a context that has run no structured child
-      // carries no capture tool.
       expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
       const run = ctx.subagents.start('spawn', structuredRequest(parent))
+      // A backend hot-reload mid-run must not unregister the capture tool out
+      // from under the live child: the registration rides the CHILD's fiber.
+      disposeProvider()
       const result = await run.result
-      // The capture succeeded — the registrations existed while the run lived.
       expect(result.structured).toEqual({ answer: 4 })
-      // The run's settle released the last acquisition.
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      const child = ctx.agents.get(run.id)!
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
       await run.dispose()
-    })
-
-    it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
-      const { ctx, parent } = await setup([
-        toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
-        toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
-      ])
-      const first = ctx.subagents.start('spawn', structuredRequest(parent))
-      const second = ctx.subagents.start('spawn', structuredRequest(parent))
-      const [a, b] = await Promise.all([first.result, second.result])
-      expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
-      await first.dispose()
-      await second.dispose()
-    })
-
-    it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
-      const ctx = new Context()
-      await ctx.plugin(SystemPrompt)
-      await ctx.plugin(ToolRegistry)
-      const first = acquireStructuredRuntime(ctx)
-      const second = acquireStructuredRuntime(ctx)
-      first.release()
-      first.release()
-      // The second holder still keeps the tool registered.
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
-      second.release()
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
-    })
-
-    it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
-      // The Loader starts sibling plugins concurrently, so a backend can
-      // acquire the runtime before dsh-tools has applied. The capture tool
-      // must then register as soon as `tools` exists — via the inject fiber,
-      // not by deferring the backend (which would reorder the prompt's tools).
-      const ctx = new Context()
-      const acquisition = acquireStructuredRuntime(ctx)
-      await ctx.plugin(SystemPrompt)
-      await ctx.plugin(ToolRegistry)
-      // Fiber activation completes asynchronously after the service appears.
-      await new Promise(resolve => setImmediate(resolve))
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
-      acquisition.release()
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
-    })
-
-    it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
-      const ctx = new Context()
-      const acquisition = acquireStructuredRuntime(ctx)
-      acquisition.release()
-      await ctx.plugin(SystemPrompt)
-      await ctx.plugin(ToolRegistry)
-      await new Promise(resolve => setImmediate(resolve))
-      // The disposed fiber never fires: nothing registers after the fact.
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
-    })
-
-    it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
-      const { ctx, parent } = await setup([])
-      const acquisition = acquireStructuredRuntime(ctx)
-      expect(acquisition.captured(parent)).toBeUndefined()
-      acquisition.attach(parent, SCHEMA)
-      expect(acquisition.captured(parent)).toBeUndefined()
-      acquisition.detach(parent)
-      acquisition.detach(parent)
-      acquisition.release()
-      // That manual acquisition was the ONLY holder - release disposes.
-      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
+      // Child disposed ⇒ its scoped registrations are gone.
+      expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
     })
   })
 
-  it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
+  it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
     const { ctx, parent } = await setup([])
-    // Hold the runtime open (run-scoped: nothing is registered otherwise) so
-    // the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
-    const hold = acquireStructuredRuntime(ctx)
     const result = await ctx.tools.execute({
       callId: 'x' as never,
       name: STRUCTURED_OUTPUT_TOOL,
@@ -588,19 +476,17 @@ describe('in-process structured output', () => {
       agent: parent,
     })
     expect(result.isError).toBe(true)
-    expect(JSON.stringify(result.content)).toContain('only available to subagents')
-    hold.release()
+    expect(result.error?.code).toBe('UNKNOWN_TOOL')
   })
 
-  it('a structured_output call with NO calling agent at all is an isError', async () => {
+  it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
     const { ctx } = await setup([])
-    const hold = acquireStructuredRuntime(ctx)
     const result = await ctx.tools.execute({
       callId: 'x' as never,
       name: STRUCTURED_OUTPUT_TOOL,
       arguments: { answer: 1 },
     })
     expect(result.isError).toBe(true)
-    hold.release()
+    expect(result.error?.code).toBe('UNKNOWN_TOOL')
   })
 })

+ 6 - 5
packages/subagent/subagent-spawn/src/index.ts

@@ -43,13 +43,14 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * The spawn provider. Supports `depthLimit` (it constructs the child, so it can
- * enforce a recursion cap) and `outputSchema` (via the shared in-process
- * structured runtime); NOT `toolFilter` in this cut — a request that needs it
- * is rejected by the service before `start` runs.
+ * The spawn provider. Supports every start-time capability: `depthLimit` (it
+ * constructs the child, so it can enforce a recursion cap), `outputSchema`
+ * (the scoped structured runtime), and `toolFilter`/`persona` (scoped
+ * `restrict()` and a scoped shadowing persona section, applied in the child's
+ * creation window).
  */
 class SpawnProvider implements SubagentProvider {
-  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false }
+  readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
   // Context contract: a spawned child starts fresh — it never sees the parent conversation.
   readonly inheritsParentContext = false
 

+ 63 - 2
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -241,10 +241,10 @@ describe('dsh-subagent-spawn', () => {
     await parentHandle.dispose()
   })
 
-  it('advertises depthLimit and outputSchema but not toolFilter', async () => {
+  it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => {
     const { ctx } = await setup([])
     const provider = ctx.subagents.getProvider('spawn')!
-    expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false })
+    expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true })
   })
 
   it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
@@ -316,4 +316,65 @@ describe('dsh-subagent-spawn', () => {
     expect(unwrapped.inject).toEqual(['subagents', 'agents'])
     expect(typeof unwrapped.apply).toBe('function')
   })
+
+  describe('persona and toolFilter (the scoped child world)', () => {
+    it('a per-child persona shadows the deployment persona in the child request only', async () => {
+      const { ctx, parent, adapter } = await setup([
+        textResponse('parent answer'),
+        textResponse('child answer'),
+      ])
+      parent.send([{ type: 'text', text: 'hi' }])
+      await parent.whenIdle()
+
+      const run = ctx.subagents.start('spawn', {
+        prompt: [{ type: 'text', text: 'do X' }],
+        parent,
+        persona: 'You are the tersest test runner.',
+      })
+      await run.result
+      const childRequest = adapter.requests.at(-1)!
+      expect(childRequest.system).toContain('You are the tersest test runner.')
+      // The parent's earlier request carried no such persona.
+      expect(adapter.requests[0]!.system ?? '').not.toContain('tersest test runner')
+      await run.dispose()
+    })
+
+    it('toolFilter hides denied tools from the child prompt AND refuses their execution', async () => {
+      const { ctx, parent, adapter } = await setup([
+        // The child tries the denied tool anyway, then answers.
+        toolCallResponse('c1', 'forbidden_tool', {}),
+        textResponse('done'),
+      ])
+      ctx.tools.register({
+        name: 'forbidden_tool', description: 'global', parameters: {},
+        execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
+      })
+      const run = ctx.subagents.start('spawn', {
+        prompt: [{ type: 'text', text: 'do X' }],
+        parent,
+        toolFilter: { deny: ['forbidden_tool'] },
+      })
+      const result = await run.result
+      expect(result.stopReason).toBe('completed')
+      // Not advertised…
+      const childRequest = adapter.requests[0]!
+      expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool')
+      // …and the attempted call executed as UNKNOWN_TOOL (visible in the log).
+      const child = ctx.agents.get(run.id)!
+      const toolResult = child.session.events.find(e => e.type === 'tool/result')!
+      expect(JSON.stringify(toolResult.data)).toContain('unknown tool')
+      await run.dispose()
+    })
+
+    it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
+      const { ctx, parent } = await setup([])
+      const before = ctx.agents.list().length
+      expect(() => ctx.subagents.start('spawn', {
+        prompt: [{ type: 'text', text: 'do X' }],
+        parent,
+        toolFilter: { deny: ['no_such_tool'] },
+      })).toThrow(/unknown tool "no_such_tool"/)
+      expect(ctx.agents.list().length).toBe(before)
+    })
+  })
 })

+ 17 - 7
packages/subagent/subagent/src/index.ts

@@ -33,9 +33,10 @@
  */
 
 import { Context, Service } from 'cordis'
+import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { AgentId } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
 import type {
   SubagentCapabilities,
   SubagentProvider,
@@ -223,7 +224,7 @@ export class SubagentService extends Service {
     // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
     // surrounding try/catch is not enough — each listener is invoked and
     // contained individually.
-    this.emitLifecycle('subagent/start', { provider: name, id: run.id })
+    this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent)
     // Emit `subagent/end` when the run settles. The result promise does not
     // reject on a child-level failure (it resolves with stopReason 'error'),
     // so a rejection here is an infrastructure fault — surface its stop reason
@@ -253,9 +254,9 @@ export class SubagentService extends Service {
         } catch (error: unknown) {
           this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
         }
-        this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} })
+        this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent)
       },
-      () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) },
+      () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) },
     )
     return run
   }
@@ -280,14 +281,22 @@ export class SubagentService extends Service {
    * listener unwinds the yielded rollback — the same fail-loud register-time
    * semantics as the system-prompt registries.
    */
-  private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo): void
-  private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo): void
+  private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
+  private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
   private emitLifecycle(name: 'subagent/provider-removed', info: string): void
   private emitLifecycle(
     name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
     info: SubagentRunInfo | SubagentRunEndInfo | string,
+    parent?: Agent,
   ): void {
-    for (const callback of this.ctx.events.dispatch('emit', [name, info])) {
+    // Run lifecycle events dispatch in the DELEGATING PARENT's scope (a
+    // parent-scoped listener observes only its own delegations); the
+    // provider-removed registry notification stays unfiltered. The carrier is
+    // args[0] of the dispatch call, exactly as cordis' own emit spells it.
+    const dispatchArgs: unknown[] = parent === undefined
+      ? [name, info]
+      : [scopeTarget(this, parent), name, info]
+    for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
       try {
         callback(info)
       } catch (error: unknown) {
@@ -306,6 +315,7 @@ export class SubagentService extends Service {
       { when: request.outputSchema !== undefined, cap: 'outputSchema' },
       { when: request.maxDepth !== undefined, cap: 'depthLimit' },
       { when: request.toolFilter !== undefined, cap: 'toolFilter' },
+      { when: request.persona !== undefined, cap: 'persona' },
     ]
     for (const { when, cap } of needs) {
       if (when && !provider.capabilities[cap]) {

+ 14 - 1
packages/subagent/subagent/src/types.ts

@@ -29,6 +29,8 @@ export interface SubagentCapabilities {
   depthLimit: boolean
   /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
   toolFilter: boolean
+  /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
+  persona: boolean
 }
 
 /**
@@ -73,9 +75,20 @@ export interface SubagentStartRequest {
   maxDepth?: number
   /**
    * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
-   * rejected at start otherwise.
+   * rejected at start otherwise. In-process backends apply it as a scoped
+   * `tools.restrict()` in the child's creation window: the named tools vanish
+   * from the child's prompt AND refuse to execute (one visibility), with loud
+   * unknown-name validation.
    */
   toolFilter?: { allow?: string[]; deny?: string[] }
+  /**
+   * Optional per-child persona. Requires {@link SubagentCapabilities.persona};
+   * rejected at start otherwise. In-process backends register it as a scoped
+   * `deployment:persona` section on the child, SHADOWING the deployment's
+   * persona for this child alone — same template semantics as the deployment
+   * persona (strict `{{…}}` interpolation against the registered variables).
+   */
+  persona?: string
 }
 
 /**

+ 2 - 2
packages/subagent/subagent/tests/service.spec.ts

@@ -16,8 +16,8 @@ function fakeParent(id = 'parent-1'): Agent {
   return { id: AgentId(id) } as unknown as Agent
 }
 
-const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
-const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false }
+const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }
+const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
 
 /** A scripted provider whose run settles immediately with a fixed result. */
 class StubProvider implements SubagentProvider {

+ 41 - 3
packages/subagent/tool-subagent/src/index.ts

@@ -54,11 +54,35 @@ export interface Config {
   toolName?: string
   /**
    * Default per-child agent options (model) applied to every spawned child.
-   * Omitted fields fall back to the child loop's own defaults. There is no
-   * per-child persona: the deployment persona (the system-prompt plugin's
-   * `persona` config) is a context-wide section every agent shares.
+   * Omitted fields fall back to the child loop's own defaults.
    */
   agentOptions?: AgentOptions
+  /**
+   * Per-child persona applied to every child this tool spawns: a scoped
+   * `deployment:persona` section shadowing the deployment's persona for the
+   * child alone. Requires the bound provider's `persona` capability
+   * (in-process backends support it; a request against one that doesn't is
+   * rejected at start). Omitted ⇒ the child renders the deployment persona.
+   */
+  persona?: string
+  /**
+   * Tool scoping applied to every child this tool spawns (see
+   * `SubagentStartRequest.toolFilter`): the named global tools vanish from
+   * the child's prompt AND refuse to execute. Requires the provider's
+   * `toolFilter` capability. Unknown names fail the spawn loudly. Note the
+   * child otherwise sees every global tool — including this delegation tool
+   * itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
+   * bounds recursion.
+   */
+  toolFilter?: { allow?: string[]; deny?: string[] }
+  /**
+   * Recursion cap applied to every child this tool spawns (see
+   * `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
+   * than this in the delegation tree is rejected. Requires the provider's
+   * `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
+   * that expose this tool to children).
+   */
+  maxDepth?: number
 }
 
 export const Config: z<Config> = z.object({
@@ -67,6 +91,17 @@ export const Config: z<Config> = z.object({
   agentOptions: z.object({
     model: z.string(),
   }),
+  persona: z.string(),
+  // A schemastery object materializes {} (with [] for nested arrays) when the
+  // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
+  // deny-everything, silently. Force the omitted key to stay absent (the same
+  // shape discipline as SystemPrompt's toolOrder); the cast is needed because
+  // .default() expects the object type.
+  toolFilter: z.object({
+    allow: z.array(z.string()),
+    deny: z.array(z.string()),
+  }).default(undefined as unknown as { allow: string[]; deny: string[] }),
+  maxDepth: z.number(),
 })
 
 /**
@@ -179,6 +214,9 @@ export function apply(ctx: Context, config: Config): void {
           parent,
           ...exec.signal ? { signal: exec.signal } : {},
           ...config.agentOptions ? { agentOptions: config.agentOptions } : {},
+          ...config.persona !== undefined ? { persona: config.persona } : {},
+          ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
+          ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
         }
 
         const run: SubagentRun = ctx.subagents.start(config.provider, request)

+ 7 - 7
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -111,7 +111,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'weird',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: () => ({
         id: AgentId('weird-child'),
@@ -137,7 +137,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'capture',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: (request) => {
         seen = request
@@ -167,7 +167,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'bare',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: (request) => {
         seen = request
@@ -297,7 +297,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'spy',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: () => ({
         id: AgentId('spy-child'),
@@ -320,7 +320,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'spy',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: () => ({
         id: AgentId('spy-child'),
@@ -344,7 +344,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'spy',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: () => {
         let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
@@ -391,7 +391,7 @@ describe('dsh-tool-subagent', () => {
     await ctx.plugin(SubagentService)
     ctx.subagents.registerProvider({
       name: 'spy',
-      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
+      capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
       inheritsParentContext: false,
       start: () => {
         let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void

+ 2 - 1
packages/support/subagent-mock/src/index.ts

@@ -26,7 +26,7 @@ import type {
 
 const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const
 
-const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
+const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
 
 /**
  * A scripted provider: every {@link start} returns a run whose `result`
@@ -111,6 +111,7 @@ export const Config: z<Config> = z.object({
     outputSchema: z.boolean(),
     depthLimit: z.boolean(),
     toolFilter: z.boolean(),
+    persona: z.boolean(),
   }),
   inheritsParentContext: z.boolean(),
   structured: z.any(),