From 15f4d1cd03a1686dee729a74b97a1ce6db703f64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:06 +0800 Subject: [PATCH] feat(subagent): persona + toolFilter become real; structured runtime collapses to scoped registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/subagent/subagent-acp/src/index.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 2 +- packages/subagent/subagent-fork/src/index.ts | 6 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/src/index.ts | 73 +++- .../subagent-inprocess/src/structured.ts | 400 ++++++------------ .../tests/structured.spec.ts | 206 ++------- packages/subagent/subagent-spawn/src/index.ts | 11 +- .../tests/subagent-spawn.spec.ts | 65 ++- packages/subagent/subagent/src/index.ts | 24 +- packages/subagent/subagent/src/types.ts | 15 +- .../subagent/subagent/tests/service.spec.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 44 +- .../tool-subagent/tests/tool-subagent.spec.ts | 14 +- packages/support/subagent-mock/src/index.ts | 3 +- 15 files changed, 398 insertions(+), 475 deletions(-) diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index b2ddd9ab2d..dd0aeb5aea 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -89,7 +89,7 @@ type ResolvedConfig = Required> & Pick * 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 diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 3eb12fac38..6bf3413485 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/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 () => { diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index d8c77a03ac..de650fa4cd 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/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 diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 90e2d583d8..5356eba6e0 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/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 () => { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 9026954b97..889a8afb5d 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/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 + 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 { 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() }, } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index a557e44785..9d8b4cb70b 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/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 - readonly disposers: (() => void)[] -} - -/** One root context ⇒ one runtime (multi-app test isolation). */ -const runtimes = new WeakMap() - -/** - * 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. - */ -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 + 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). + * 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 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) - } - runtime.refs += 1 +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 - 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) - }, - 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() - }, + 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, } -} -/** 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 { - 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.tools.register({ + ...schemaEntry, + execute(args: unknown, exec: ToolExecution): Promise { + 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.' }]) + }, }) - // 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, + childCtx.systemPrompt.section({ + name: `tool:${STRUCTURED_OUTPUT_TOOL}`, + order: 190, + text: STRUCTURED_OUTPUT_INSTRUCTION, + }) + + // 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, ): Promise { 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, - } - 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, + // 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, ): Promise { - if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) + if (captured) return Promise.resolve({ action: 'stop' }) 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 ( - this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, - ): Promise { - 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 - try { - const decision = await next() - if (decision.kind === 'accept') state.captured = pending - return decision - } finally { - delete state.pending - } - }, { 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 ( + // 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, ): Promise { - if (exec.agent && runtime.states.get(exec.agent)?.captured) { + 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 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, + ): Promise { + 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') captured = { value: staged.value } + return decision + } finally { + if (pending === staged) pending = undefined + } + }, { prepend: true }) + + return { captured: () => captured } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 0de036a0a0..3d676c3848 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/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({ 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({ 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') }) }) diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 2d8f118b4e..53fa5967fd 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -43,13 +43,14 @@ export const Config: z = 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 diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 1dad9748e9..0d3bc57d26 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/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) + }) + }) }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 91230b7ff4..fc78efa674 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/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]) { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb512c0bdb..5e6553c48a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/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 } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index f70abf7e72..66c3e80042 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/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 { diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f48ef4345e..c305ea40db 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/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 = z.object({ @@ -67,6 +91,17 @@ export const Config: z = 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) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 611069ef76..90f3e0f931 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/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 diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index e2effe5b4b..e72765ba4d 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/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 = z.object({ outputSchema: z.boolean(), depthLimit: z.boolean(), toolFilter: z.boolean(), + persona: z.boolean(), }), inheritsParentContext: z.boolean(), structured: z.any(),