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).
This commit is contained in:
Tianyi Cui
2026-07-09 02:10:06 +08:00
parent 67cb9a591d
commit 15f4d1cd03
15 changed files with 398 additions and 475 deletions

View File

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

View File

@@ -530,7 +530,7 @@ describe('dsh-subagent-acp', () => {
it('advertises no start-time capabilities (out-of-process child)', async () => { it('advertises no start-time capabilities (out-of-process child)', async () => {
const ctx = await setup() const ctx = await setup()
const provider = ctx.subagents.getProvider('acp')! 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 () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {

View File

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

View File

@@ -182,9 +182,9 @@ describe('dsh-subagent-fork', () => {
await run.dispose() 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([]) 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 () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {

View File

@@ -21,13 +21,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { import {
acquireStructuredRuntime, attachStructuredRuntime,
type StructuredAcquisition, type StructuredAttachment,
} from './structured.ts' } from './structured.ts'
// The runtime itself (acquire/attach/release) is package-internal: runs // The runtime itself (attach) is package-internal: runs attach it inside
// acquire it inside startInProcessRun, and no other package drives it. Only // startInProcessRun's setup window, and no other package drives it. Only the
// the model-facing vocabulary is public. // model-facing vocabulary is public.
export { export {
STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_INSTRUCTION,
@@ -143,21 +143,36 @@ export function startInProcessRun(
const seedLength = options.seed?.length ?? 0 const seedLength = options.seed?.length ?? 0
const parentHeader = request.parent.session.header const parentHeader = request.parent.session.header
// Inherit the parent's model by default (a child with no model cannot run); // Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The persona needs // an explicit `request.agentOptions.model` overrides it. The deployment
// no inheritance: the deployment persona is a context-wide prompt section, // persona needs no inheritance (a context-wide section both render); a
// so parent and child render the same one. A structured run's // per-child `request.persona` becomes a SCOPED section of the same name in
// structured_output instruction is NOT prompt state either — the structured // the setup below, shadowing the deployment's for this child alone.
// runtime's final-request listener appends it per request (see structured.ts).
const agentOptions: AgentOptions = { const agentOptions: AgentOptions = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
...request.agentOptions, ...request.agentOptions,
subagentDepth: childDepth, subagentDepth: childDepth,
} }
// The structured runtime is held for the WHOLE run (acquired before the child // The child's scoped world, composed in the factory's setup window (after
// exists, released when the result settles), so a backend hot-reload mid-run // the child's scope exists and it is registered, before agent/session-start
// cannot unregister the capture tool out from under this live child. // and the first prompt assembly; a throw here unwinds the half-created
const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined // 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({ const handle: AgentHandle = ctx.agents.create({
agentId: childId, agentId: childId,
@@ -171,9 +186,26 @@ export function startInProcessRun(
}, },
...options.seed !== undefined ? { seed: options.seed } : {}, ...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions, agentOptions,
setup,
}) })
const child = handle.agent 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 // 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). // 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 // Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` — // without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns. // 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 { } finally {
request.signal?.removeEventListener('abort', onAbort) request.signal?.removeEventListener('abort', onAbort)
if (structured) {
structured.detach(child)
structured.release()
}
} }
})() })()
@@ -223,6 +251,11 @@ export function startInProcessRun(
}, },
async dispose(): Promise<void> { async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort) 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() await handle.dispose()
}, },
} }

View File

@@ -1,63 +1,49 @@
/** /**
* Structured-output support for the in-process subagent backends: the mechanism * Structured-output support for the in-process subagent backends: the
* behind `SubagentStartRequest.outputSchema` for children that run as agents on * mechanism behind `SubagentStartRequest.outputSchema` for children that run
* the same context. * as agents on the same context.
* *
* The model-facing surface is one globally registered `structured_output` tool * Everything is a SCOPED registration on the child agent's context
* whose REGISTERED parameters are a placeholder — the real schema is per run. * (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
* Because the tool registry and prompt assembly are context-global while * carries the run's REAL schema as its registered parameters (each child sees
* schemas differ per child (two concurrent structured runs may carry different * exactly its own schema — two concurrent structured runs never interact), the
* schemas), per-agent shaping happens on the `system-prompt/assemble` * demand instruction is an ordinary order-190 scoped section, and the
* waterfall with a `prepend: true` listener that post-processes `await next()` * enforcement listeners fire only for this child (scope-filtered dispatch).
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or * Registration lifetime rides the child's fiber, so a backend hot-reload
* replaced, the assembly the loop renders never carries `structured_output` * mid-run cannot unregister the capture tool out from under a live child, and
* for an agent without a structured run, and for one that has it always * a disposed child leaves no residue — no placeholder schema, no
* carries the run's OWN schema plus a trailing * strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
* {@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.)
* *
* FIXME: the whole enforcement dance above exists because the tool registry * Four listeners enforce the contract:
* 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.
* *
* A companion `agent/turn-continuation` listener stops a child's turn once its * - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
* output is captured — without it, the loop's default "had tool calls ⇒ * whatever downstream listeners mutated or replaced, the child's assembly
* continue" buys a wasted extra model step per structured child. It is also * always carries its capture tool and the trailing instruction section. The
* `prepend: true`: the veto must run before any earlier-registered listener * registry already contributes both; this outermost wrapper preserves the
* that could short-circuit the chain into a forced continue. A third listener * guarantee against a (global) listener that strips or replaces the
* closes the within-step window the continuation veto cannot: a * assembly. The loop logs the rendered assembly as the request header, so
* `tools/pre-execute` deny for any call arriving after the agent's capture, so * the demand is reconstructable log state, never a wire-only mutation.
* a response that lists `structured_output` before further tool calls cannot * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
* run side effects after the final answer was accepted. A fourth, * its output is captured — the loop's default "had tool calls ⇒ continue"
* `tools/post-execute`, is the capture COMMIT: the tool body only stages the * would buy a wasted extra model step per structured child.
* validated value, and it becomes the run's captured result only when the * - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
* final post-execute decision accepts the call — a blocking hook downstream * step — deny every call arriving after the capture, so a response that
* yields `isError` in the log, and the run must not report success for it. * lists `structured_output` before further tool calls cannot run side
* * effects after the final answer was accepted.
* Lifetime is refcounted by structured RUNS: each acquires from start to * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
* settle, so the registrations exist exactly while at least one structured * only STAGES the validated value, KEYED BY CALL ID; it becomes the run's
* child is live — a plain deployment that never passes `outputSchema` carries * captured result only when the final post-execute decision accepts THAT
* no always-on global state, and a backend hot-reload mid-run cannot * call. Call-keyed staging closes a stale-stage hole: an outer
* unregister the capture tool out from under a live child (the run holds its * short-circuiting post-execute listener can orphan a staged value, and an
* own acquisition). Registrations land on the ROOT context and the refcount * un-keyed commit would then promote it on a LATER call's acceptance —
* disposes them when the last run settles; the next structured run * reporting success for a value the model saw fail.
* re-registers them.
* *
* @module @deepseek-ai/dsh-subagent-inprocess/structured * @module @deepseek-ai/dsh-subagent-inprocess/structured
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } 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' export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/** /**
* The instruction the assembly listener appends to a structured child's * The instruction registered as the child's trailing (order-190, the end of
* system prompt as a trailing section on every assembly. Per-assembly state, * the tool-guidance band) scoped prompt section: the demand travels with the
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is * tool, as ordinary prompt state of exactly one agent.
* 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.
*/ */
export const STRUCTURED_OUTPUT_INSTRUCTION export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the ' = 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + `\`${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.' + '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. */ /** One structured run's live handle: read the captured value once the child settles. */
interface RunState { export interface StructuredAttachment {
readonly schema: StructuredOutputSchema
/** /**
* A validated value awaiting the post-execute verdict on ITS OWN call. Set * The captured value, once the child called the tool with valid arguments
* by the capture tool's body, promoted to {@link RunState.captured} only * and the final post-execute decision accepted that call.
* when the final `tools/post-execute` decision accepts the call — a * @returns the committed value, or undefined while none was accepted.
* 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.
*/ */
pending?: { value: unknown } captured(): { value: unknown } | undefined
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)[]
}
/** 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.
*/
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
} }
/** /**
* Acquire the per-root-context structured runtime, registering the capture tool * Attach the structured-output runtime to a child for `schema`: register the
* and the runtime's listeners on the FIRST acquisition. See the module doc * scoped capture tool (real schema), the scoped instruction section, and the
* for the enforcement and lifetime design. * four scoped enforcement listeners (see the module doc). Call from the
* @param ctx - any context of the app; the runtime keys off `ctx.root`. * agent-creation `setup` window with the child's scope context — every
* @returns this holder's handle (attach/captured/detach + idempotent release). * 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 { export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
const root: Context = ctx.root /** A validated value staged by the capture tool body, awaiting ITS OWN call's post-execute verdict. */
let runtime = runtimes.get(root) let pending: { callId: CallId; value: unknown } | undefined
if (!runtime) { let captured: { value: unknown } | undefined
runtime = { refs: 0, states: new WeakMap(), disposers: [] }
runtimes.set(root, runtime)
registerRuntime(root, runtime)
}
runtime.refs += 1
let released = false const schemaEntry: ToolSchema = {
return { name: STRUCTURED_OUTPUT_TOOL,
attach(agent: Agent, schema: StructuredOutputSchema): void { description:
runtime.states.set(agent, { schema }) 'Report your final structured result. Call this exactly once, when your answer is complete; '
}, + 'the arguments must match this tool\'s parameter schema exactly.',
captured(agent: Agent): { value: unknown } | undefined { // ToolSchema.parameters is the wire-level JSON Schema object; the
return runtime.states.get(agent)?.captured // asserted subset type is structurally exactly that.
}, parameters: schema as unknown as Record<string, unknown>,
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()
},
} }
}
/** Register the capture tool + the two listeners on the root context (first acquire). */ childCtx.tools.register({
function registerRuntime(root: Context, runtime: StructuredRuntime): void { ...schemaEntry,
// The registered parameters are a PLACEHOLDER: the request listener below execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
// swaps in the run's real schema per child, and strips the tool entirely for const violations = validateStructuredValue(schema, args)
// every agent without a structured run — so this shape is never model-visible. // ToolArgsError → isError result with INVALID_ARGS: the model retries
// // within the same turn, exactly like a schema-validated defineTool call.
// Registration does NOT ride on the acquiring backend's plugin-level if (violations.length > 0) throw new ToolArgsError(violations)
// `inject`: a backend that waited on `tools` would apply later than it did // Two-phase commit, KEYED BY THIS CALL: the body only stages; the
// before this module existed, shifting when its PROVIDER registers — and the // post-execute listener promotes exactly this call's entry when the
// delegation tool mirrors provider lifecycle, so that shift would reorder // final decision accepts it.
// the model-visible tool list of every existing prompt. Instead the capture pending = { callId: exec.callId, value: args }
// tool registers synchronously when `tools` is already live (the common return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
// 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()
}) })
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST childCtx.systemPrompt.section({
// wrapper): post-process whatever the downstream listeners and the registry name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
// produced, so a downstream listener returning a replacement assembly cannot order: 190,
// leak the tool to other agents or erase the child's schema. The loop logs text: STRUCTURED_OUTPUT_INSTRUCTION,
// 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 ( // FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>, // 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> { ): Promise<PromptAssembly> {
const final = await next() const final = await next()
const state = context.agent ? runtime.states.get(context.agent) : undefined if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
if (state) { final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }]
const schemaEntry: ToolSchema = { }
name: STRUCTURED_OUTPUT_TOOL, if (!final.sections.some(section => section.name === `tool:${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).
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }] 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 return final
}, { prepend: true })) }, { prepend: true })
// Stop a structured child's turn once its output is captured: the default // Stop the child's turn once its output is captured. `prepend: true` puts
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step // the veto OUTERMOST — an earlier-registered listener that short-circuits
// after every successful capture. `prepend: true` puts the veto OUTERMOST — // the chain (a goal-style force-continue returning without `next()`) would
// an earlier-registered listener that short-circuits the chain (a goal-style // otherwise decide the turn before this listener ever ran, and no
// force-continue returning without `next()`) would otherwise decide the turn // downstream decision may resurrect a structured turn that is finished.
// before this listener ever ran, and no downstream decision may resurrect a childCtx.on('agent/turn-continuation', function (
// structured turn that is already finished. this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
runtime.disposers.push(root.on('agent/turn-continuation', function (
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
): Promise<ContinuationDecision> { ): Promise<ContinuationDecision> {
if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) if (captured) return Promise.resolve({ action: 'stop' })
return next() return next()
}, { prepend: true })) }, { prepend: true })
// The capture COMMIT: promote the staged value only when the final // Terminal WITHIN the step: deny every call after the capture. Calls that
// post-execute decision accepts the call. The capture tool's body cannot // PRECEDE the capture in the same response ran before `captured` was set
// decide — `tools/post-execute` runs after it, and a blocking listener (a // and are untouched; a second `structured_output` is denied like any other.
// PostToolUse hook) turns the logged result into `isError` feedback; a value childCtx.on('tools/pre-execute', function (
// 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<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
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 (
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>, this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
): Promise<PreToolDecision> { ): Promise<PreToolDecision> {
if (exec.agent && runtime.states.get(exec.agent)?.captured) { if (captured) {
return Promise.resolve({ return Promise.resolve({
kind: 'deny', kind: 'deny',
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`, reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
}) })
} }
return next() 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<PostToolDecision>,
): Promise<PostToolDecision> {
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 }
} }

View File

@@ -5,7 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' 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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants' import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' 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 { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts' import { startInProcessRun } from '../src/index.ts'
import { import {
acquireStructuredRuntime,
STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_TOOL,
} from '../src/structured.ts' } from '../src/structured.ts'
@@ -47,7 +46,7 @@ async function setup(script: Script) {
await ctx.plugin(SubagentService) await ctx.plugin(SubagentService)
const disposeProvider = ctx.subagents.registerProvider({ const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn', name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false }, capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false, inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }), 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 () => { it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
const ctx = new Context() // A goal-style listener registered BEFORE the child exists, returning a
await ctx.plugin(SystemPrompt) // forced continue WITHOUT calling next(). Without prepend on the scoped
await ctx.plugin(ToolRegistry) // veto, this would decide the turn first and buy a wasted model step —
// Registered BEFORE the structured runtime exists — without prepend, this // the one-response script would then throw on the second request.
// goal-style listener would decide the turn first (returning WITHOUT const { ctx, parent, adapter } = await setup([
// calling next()) and the veto would never run. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' })) ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
const acquisition = acquireStructuredRuntime(ctx) const run = ctx.subagents.start('spawn', structuredRequest(parent))
const agent = { id: AgentId('structured-child') } as unknown as Agent const result = await run.result
acquisition.attach(agent, SCHEMA) expect(result.structured).toEqual({ answer: 7 })
const captured = await ctx.tools.execute({ expect(result.stopReason).toBe('completed')
callId: 'call-1' as never, expect(adapter.requests).toHaveLength(1)
name: STRUCTURED_OUTPUT_TOOL, await run.dispose()
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()
}) })
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => { 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() await run.dispose()
}) })
describe('final-request enforcement (the prepend agent/request listener)', () => { describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => { it('a plain agent never sees the tool: nothing is registered globally at all', 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.
const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
const hold = acquireStructuredRuntime(ctx)
parent.send([{ type: 'text', text: 'hello' }]) parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle() await parent.whenIdle()
// The placeholder IS in the registry during this turn; the assembly the // Scoped registration: the global view has no capture tool, ever.
// loop rendered must not carry it for an agent without a structured run. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) 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 () => { 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() 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([ const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
]) ])
// A downstream (non-prepend) listener that returns a brand-new assembly — // A global (every-assembly) listener that returns a brand-new assembly
// the composition caveat that erases cooperative mutations. Registered // WITHOUT the capture tool or instruction — the composition caveat that
// AFTER the runtime's prepend listener, so it runs INSIDE it. // erases cooperative mutations. The child's prepend re-assert runs
// OUTERMOST and restores both.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const replaced = await 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 run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result 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) const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
expect(entry).toBeDefined() expect(entry).toBeDefined()
expect(entry!.parameters).toEqual(SCHEMA) expect(entry!.parameters).toEqual(SCHEMA)
const system = adapter.requests[0]!.system ?? ''
expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose() await run.dispose()
}) })
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([ const { parent, adapter } = await setup([textResponse('plain')])
// 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'),
])
parent.send([{ type: 'text', text: 'q' }]) parent.send([{ type: 'text', text: 'q' }])
await parent.whenIdle() await parent.whenIdle()
const request = adapter.requests[0]! const request = adapter.requests[0]!
expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL) expect(request.tools).toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0)) 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 () => { it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
// Drive ctx.systemPrompt.assemble directly — the enforcement listener const { ctx, parent, disposeProvider } = await setup([
// 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([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }), 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() expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = ctx.subagents.start('spawn', structuredRequest(parent)) 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 const result = await run.result
// The capture succeeded — the registrations existed while the run lived.
expect(result.structured).toEqual({ answer: 4 }) expect(result.structured).toEqual({ answer: 4 })
// The run's settle released the last acquisition. const child = ctx.agents.get(run.id)!
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
await run.dispose() await run.dispose()
}) // Child disposed ⇒ its scoped registrations are gone.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
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()
}) })
}) })
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([]) 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({ const result = await ctx.tools.execute({
callId: 'x' as never, callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL, name: STRUCTURED_OUTPUT_TOOL,
@@ -588,19 +476,17 @@ describe('in-process structured output', () => {
agent: parent, agent: parent,
}) })
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
expect(JSON.stringify(result.content)).toContain('only available to subagents') expect(result.error?.code).toBe('UNKNOWN_TOOL')
hold.release()
}) })
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 { ctx } = await setup([])
const hold = acquireStructuredRuntime(ctx)
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({
callId: 'x' as never, callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL, name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 }, arguments: { answer: 1 },
}) })
expect(result.isError).toBe(true) expect(result.isError).toBe(true)
hold.release() expect(result.error?.code).toBe('UNKNOWN_TOOL')
}) })
}) })

View File

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

View File

@@ -241,10 +241,10 @@ describe('dsh-subagent-spawn', () => {
await parentHandle.dispose() 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 { ctx } = await setup([])
const provider = ctx.subagents.getProvider('spawn')! 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 () => { 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(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(typeof unwrapped.apply).toBe('function') 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)
})
})
}) })

View File

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

View File

@@ -29,6 +29,8 @@ export interface SubagentCapabilities {
depthLimit: boolean depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
toolFilter: boolean toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
persona: boolean
} }
/** /**
@@ -73,9 +75,20 @@ export interface SubagentStartRequest {
maxDepth?: number maxDepth?: number
/** /**
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; * 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[] } 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
} }
/** /**

View File

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

View File

@@ -54,11 +54,35 @@ export interface Config {
toolName?: string toolName?: string
/** /**
* Default per-child agent options (model) applied to every spawned child. * 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 * Omitted fields fall back to the child loop's own defaults.
* per-child persona: the deployment persona (the system-prompt plugin's
* `persona` config) is a context-wide section every agent shares.
*/ */
agentOptions?: AgentOptions 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({ export const Config: z<Config> = z.object({
@@ -67,6 +91,17 @@ export const Config: z<Config> = z.object({
agentOptions: z.object({ agentOptions: z.object({
model: z.string(), 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, parent,
...exec.signal ? { signal: exec.signal } : {}, ...exec.signal ? { signal: exec.signal } : {},
...config.agentOptions ? { agentOptions: config.agentOptions } : {}, ...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) const run: SubagentRun = ctx.subagents.start(config.provider, request)

View File

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

View File

@@ -26,7 +26,7 @@ import type {
const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const 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` * 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(), outputSchema: z.boolean(),
depthLimit: z.boolean(), depthLimit: z.boolean(),
toolFilter: z.boolean(), toolFilter: z.boolean(),
persona: z.boolean(),
}), }),
inheritsParentContext: z.boolean(), inheritsParentContext: z.boolean(),
structured: z.any(), structured: z.any(),