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).
*/
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

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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 () => {

View File

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

View File

@@ -1,63 +1,49 @@
/**
* Structured-output support for the in-process subagent backends: the mechanism
* behind `SubagentStartRequest.outputSchema` for children that run as agents on
* the same context.
* Structured-output support for the in-process subagent backends: the
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
* as agents on the same context.
*
* The model-facing surface is one globally registered `structured_output` tool
* whose REGISTERED parameters are a placeholder — the real schema is per run.
* Because the tool registry and prompt assembly are context-global while
* schemas differ per child (two concurrent structured runs may carry different
* schemas), per-agent shaping happens on the `system-prompt/assemble`
* waterfall with a `prepend: true` listener that post-processes `await next()`
* — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or
* replaced, the assembly the loop renders never carries `structured_output`
* for an agent without a structured run, and for one that has it always
* carries the run's OWN schema plus a trailing
* {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the
* tool). The loop logs what the assembly produced as the request header, so
* the injection is a reconstructable fact of the session log, never a
* wire-only mutation (the reconstructability RFC).
* (Cooperative mutate-then-`next()` would not survive a downstream listener
* returning a replacement assembly — see the waterfall composition caveat in
* docs/architecture.md.)
* Everything is a SCOPED registration on the child agent's context
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
* carries the run's REAL schema as its registered parameters (each child sees
* exactly its own schema — two concurrent structured runs never interact), the
* demand instruction is an ordinary order-190 scoped section, and the
* enforcement listeners fire only for this child (scope-filtered dispatch).
* Registration lifetime rides the child's fiber, so a backend hot-reload
* mid-run cannot unregister the capture tool out from under a live child, and
* a disposed child leaves no residue — no placeholder schema, no
* strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
*
* FIXME: the whole enforcement dance above exists because the tool registry
* and prompt assembly are context-global. If they become per-agent or
* per-session scoped, a structured run just registers its own schema'd tool on
* the child's scope and this module reduces to the capture tool plus the
* turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone-
* else, no global-registration lifetime dance.
* Four listeners enforce the contract:
*
* A companion `agent/turn-continuation` listener stops a child's turn once its
* output is captured — without it, the loop's default "had tool calls ⇒
* continue" buys a wasted extra model step per structured child. It is also
* `prepend: true`: the veto must run before any earlier-registered listener
* that could short-circuit the chain into a forced continue. A third listener
* closes the within-step window the continuation veto cannot: a
* `tools/pre-execute` deny for any call arriving after the agent's capture, so
* a response that lists `structured_output` before further tool calls cannot
* run side effects after the final answer was accepted. A fourth,
* `tools/post-execute`, is the capture COMMIT: the tool body only stages the
* validated value, and it becomes the run's captured result only when the
* final post-execute decision accepts the call — a blocking hook downstream
* yields `isError` in the log, and the run must not report success for it.
*
* Lifetime is refcounted by structured RUNS: each acquires from start to
* settle, so the registrations exist exactly while at least one structured
* child is live — a plain deployment that never passes `outputSchema` carries
* no always-on global state, and a backend hot-reload mid-run cannot
* unregister the capture tool out from under a live child (the run holds its
* own acquisition). Registrations land on the ROOT context and the refcount
* disposes them when the last run settles; the next structured run
* re-registers them.
* - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
* whatever downstream listeners mutated or replaced, the child's assembly
* always carries its capture tool and the trailing instruction section. The
* registry already contributes both; this outermost wrapper preserves the
* guarantee against a (global) listener that strips or replaces the
* assembly. The loop logs the rendered assembly as the request header, so
* the demand is reconstructable log state, never a wire-only mutation.
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
* its output is captured — the loop's default "had tool calls ⇒ continue"
* would buy a wasted extra model step per structured child.
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
* step — deny every call arriving after the capture, so a response that
* lists `structured_output` before further tool calls cannot run side
* effects after the final answer was accepted.
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
* only STAGES the validated value, KEYED BY CALL ID; it becomes the run's
* captured result only when the final post-execute decision accepts THAT
* call. Call-keyed staging closes a stale-stage hole: an outer
* short-circuiting post-execute listener can orphan a staged value, and an
* un-keyed commit would then promote it on a LATER call's acceptance —
* reporting success for a value the model saw fail.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
@@ -66,247 +52,141 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/**
* The instruction the assembly listener appends to a structured child's
* system prompt as a trailing section on every assembly. Per-assembly state,
* NOT agent prompt state: `AgentOptions` has no prompt field (the persona is
* deployment config on the system-prompt plugin), so the same final-assembly
* enforcement that injects the schema'd tool carries the instruction that
* demands calling it.
* The instruction registered as the child's trailing (order-190, the end of
* the tool-guidance band) scoped prompt section: the demand travels with the
* tool, as ordinary prompt state of exactly one agent.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
+ 'Do not finish with a plain text answer: only the tool call counts as your result.'
/** One structured run's state: the schema to enforce and the captured value, once recorded. */
interface RunState {
readonly schema: StructuredOutputSchema
/** One structured run's live handle: read the captured value once the child settles. */
export interface StructuredAttachment {
/**
* A validated value awaiting the post-execute verdict on ITS OWN call. Set
* by the capture tool's body, promoted to {@link RunState.captured} only
* when the final `tools/post-execute` decision accepts the call — a
* downstream block turns the logged result into `isError`, and a value
* committed at body time would let the run report success for a call the
* model saw fail.
* The captured value, once the child called the tool with valid arguments
* and the final post-execute decision accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
pending?: { value: unknown }
captured?: { value: unknown }
}
/** The per-root-context runtime: run states plus the shared registrations. */
interface StructuredRuntime {
refs: number
readonly states: WeakMap<Agent, RunState>
readonly disposers: (() => void)[]
}
/** 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
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<string, unknown>,
}
}
/** Register the capture tool + the two listeners on the root context (first acquire). */
function registerRuntime(root: Context, runtime: StructuredRuntime): void {
// The registered parameters are a PLACEHOLDER: the request listener below
// swaps in the run's real schema per child, and strips the tool entirely for
// every agent without a structured run — so this shape is never model-visible.
//
// Registration does NOT ride on the acquiring backend's plugin-level
// `inject`: a backend that waited on `tools` would apply later than it did
// before this module existed, shifting when its PROVIDER registers — and the
// delegation tool mirrors provider lifecycle, so that shift would reorder
// the model-visible tool list of every existing prompt. Instead the capture
// tool registers synchronously when `tools` is already live (the common
// case), and through a scoped inject fiber when the Loader happens to start
// the backend first. Either way the registration lands on root and is
// disposed by the runtime's refcount; disposing the fiber also covers the
// never-activated case.
let disposeTool: (() => void) | undefined
const registerCapture = (tools: Context['tools']): void => {
disposeTool = tools.register({
name: STRUCTURED_OUTPUT_TOOL,
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
parameters: { type: 'object', properties: {} },
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const state = exec.agent ? runtime.states.get(exec.agent) : undefined
if (!state) {
// Reachable only if a non-structured agent somehow calls the tool (the
// request listener strips it, so the model never sees it) — fail loud
// rather than capture into nowhere.
throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`)
}
const violations = validateStructuredValue(state.schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit: the body only STAGES the value; the post-execute
// listener below promotes it once the final decision accepts the call.
state.pending = { value: args }
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
}
const liveTools = root.get('tools')
const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => {
registerCapture(childCtx.root.tools)
})
if (liveTools) registerCapture(liveTools)
runtime.disposers.push(() => {
disposeTool?.()
void toolsFiber?.dispose()
childCtx.tools.register({
...schemaEntry,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit, KEYED BY THIS CALL: the body only stages; the
// post-execute listener promotes exactly this call's entry when the
// final decision accepts it.
pending = { callId: exec.callId, value: args }
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
// FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST
// wrapper): post-process whatever the downstream listeners and the registry
// produced, so a downstream listener returning a replacement assembly cannot
// leak the tool to other agents or erase the child's schema. The loop logs
// the rendered assembly as the step's request header, so the swap is
// reconstructable log state, never a wire-only mutation.
runtime.disposers.push(root.on('system-prompt/assemble', async function (
this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>,
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<PromptAssembly>,
): Promise<PromptAssembly> {
const final = await next()
const state = context.agent ? runtime.states.get(context.agent) : undefined
if (state) {
const schemaEntry: ToolSchema = {
name: STRUCTURED_OUTPUT_TOOL,
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
// ToolSchema.parameters is the wire-level JSON Schema object; the
// asserted subset type is structurally exactly that.
parameters: state.schema as unknown as Record<string, unknown>,
}
final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry]
// The demand travels WITH the tool: a trailing section in the
// tool-guidance order band, appended after next() so it renders last
// (renderPrompt joins in array order).
if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) {
final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }]
}
if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) {
final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }]
return final
}
// No structured run: strip the placeholder so it is never model-visible.
// An empty tools array canonicalizes to an absent header/wire field
// (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here.
final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL)
return final
}, { prepend: true }))
}, { prepend: true })
// Stop a structured child's turn once its output is captured: the default
// "had tool calls ⇒ continue" would otherwise buy a wasted extra model step
// after every successful capture. `prepend: true` puts the veto OUTERMOST —
// an earlier-registered listener that short-circuits the chain (a goal-style
// force-continue returning without `next()`) would otherwise decide the turn
// before this listener ever ran, and no downstream decision may resurrect a
// structured turn that is already finished.
runtime.disposers.push(root.on('agent/turn-continuation', function (
this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
// Stop the child's turn once its output is captured. `prepend: true` puts
// the veto OUTERMOST — an earlier-registered listener that short-circuits
// the chain (a goal-style force-continue returning without `next()`) would
// otherwise decide the turn before this listener ever ran, and no
// downstream decision may resurrect a structured turn that is finished.
childCtx.on('agent/turn-continuation', function (
this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
): Promise<ContinuationDecision> {
if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' })
if (captured) return Promise.resolve({ action: 'stop' })
return next()
}, { prepend: true }))
}, { 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<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 (
// Terminal WITHIN the step: deny every call after the capture. Calls that
// PRECEDE the capture in the same response ran before `captured` was set
// and are untouched; a second `structured_output` is denied like any other.
childCtx.on('tools/pre-execute', function (
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
): Promise<PreToolDecision> {
if (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<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 ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
@@ -13,7 +13,6 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
acquireStructuredRuntime,
STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL,
} from '../src/structured.ts'
@@ -47,7 +46,7 @@ async function setup(script: Script) {
await ctx.plugin(SubagentService)
const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false },
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
})
@@ -175,31 +174,20 @@ describe('in-process structured output', () => {
})
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// Registered BEFORE the structured runtime exists — without prepend, this
// goal-style listener would decide the turn first (returning WITHOUT
// calling next()) and the veto would never run.
// A goal-style listener registered BEFORE the child exists, returning a
// forced continue WITHOUT calling next(). Without prepend on the scoped
// veto, this would decide the turn first and buy a wasted model step —
// the one-response script would then throw on the second request.
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
const acquisition = acquireStructuredRuntime(ctx)
const agent = { id: AgentId('structured-child') } as unknown as Agent
acquisition.attach(agent, SCHEMA)
const captured = await ctx.tools.execute({
callId: 'call-1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
agent,
})
expect(captured.isError).toBeFalsy()
const decision = await ctx.waterfall(
'agent/turn-continuation', agent, 1,
{ action: 'continue' },
() => Promise.resolve<ContinuationDecision>({ action: 'continue' }),
)
expect(decision).toEqual({ action: 'stop' })
acquisition.detach(agent)
acquisition.release()
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
expect(adapter.requests).toHaveLength(1)
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
@@ -357,20 +345,14 @@ describe('in-process structured output', () => {
await run.dispose()
})
describe('final-request enforcement (the prepend agent/request listener)', () => {
it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => {
// Run-scoped acquisition means a plain deployment never registers the
// tool at all; the strip branch exists for the CONCURRENT case — a plain
// agent taking a turn while some structured child holds the runtime open.
describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
const hold = acquireStructuredRuntime(ctx)
parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
// The placeholder IS in the registry during this turn; the assembly the
// loop rendered must not carry it for an agent without a structured run.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
// Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
hold.release()
})
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
@@ -430,16 +412,21 @@ describe('in-process structured output', () => {
await runB.dispose()
})
it('wins against a downstream listener that REPLACES the assembly object', async () => {
it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
])
// A downstream (non-prepend) listener that returns a brand-new assembly
// the composition caveat that erases cooperative mutations. Registered
// AFTER the runtime's prepend listener, so it runs INSIDE it.
// A global (every-assembly) listener that returns a brand-new assembly
// WITHOUT the capture tool or instruction — the composition caveat that
// erases cooperative mutations. The child's prepend re-assert runs
// OUTERMOST and restores both.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const replaced = await next()
return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } }
return {
sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
tools: replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL),
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
@@ -447,140 +434,41 @@ describe('in-process structured output', () => {
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
expect(entry).toBeDefined()
expect(entry!.parameters).toEqual(SCHEMA)
const system = adapter.requests[0]!.system ?? ''
expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose()
})
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([
// The registry contributes the placeholder via prompt assembly, so
// tools is an array in the raw request — but after stripping the
// placeholder (its ONLY entry), the field must not be re-added as a
// different shape.
textResponse('plain'),
])
const { parent, adapter } = await setup([textResponse('plain')])
parent.send([{ type: 'text', text: 'q' }])
await parent.whenIdle()
const request = adapter.requests[0]!
expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL)
expect(request.tools).toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0))
})
it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => {
// Drive ctx.systemPrompt.assemble directly — the enforcement listener
// must tolerate a context with NO agent (a bare diagnostic assemble)
// and shape a structured agent's assembly on the same path the loop
// renders and logs as the request header.
const { ctx, parent } = await setup([])
const acquisition = acquireStructuredRuntime(ctx)
// Bare assemble WHILE the runtime is live: the no-agent branch must
// strip the registered placeholder (before the acquisition there is
// nothing to strip — run-scoped registration).
const bare = await ctx.systemPrompt.assemble({})
expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL)
acquisition.attach(parent, SCHEMA)
const shaped = await ctx.systemPrompt.assemble({ agent: parent })
expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL)
expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA)
// The demand travels with the tool: the instruction renders LAST
// (appended post-next(); renderPrompt joins in array order).
expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION })
acquisition.detach(parent)
acquisition.release()
})
})
describe('runtime lifetime (refcount: live structured runs)', () => {
it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => {
const { ctx, parent } = await setup([
it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
const { ctx, parent, disposeProvider } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
// No always-on global state: a context that has run no structured child
// carries no capture tool.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = ctx.subagents.start('spawn', structuredRequest(parent))
// A backend hot-reload mid-run must not unregister the capture tool out
// from under the live child: the registration rides the CHILD's fiber.
disposeProvider()
const result = await run.result
// The capture succeeded — the registrations existed while the run lived.
expect(result.structured).toEqual({ answer: 4 })
// The run's settle released the last acquisition.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const child = ctx.agents.get(run.id)!
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
await run.dispose()
})
it('concurrent structured runs share one runtime; the last settle disposes it', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }),
])
const first = ctx.subagents.start('spawn', structuredRequest(parent))
const second = ctx.subagents.start('spawn', structuredRequest(parent))
const [a, b] = await Promise.all([first.result, second.result])
expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort())
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await first.dispose()
await second.dispose()
})
it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const first = acquireStructuredRuntime(ctx)
const second = acquireStructuredRuntime(ctx)
first.release()
first.release()
// The second holder still keeps the tool registered.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
second.release()
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
})
it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => {
// The Loader starts sibling plugins concurrently, so a backend can
// acquire the runtime before dsh-tools has applied. The capture tool
// must then register as soon as `tools` exists — via the inject fiber,
// not by deferring the backend (which would reorder the prompt's tools).
const ctx = new Context()
const acquisition = acquireStructuredRuntime(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// Fiber activation completes asynchronously after the service appears.
await new Promise(resolve => setImmediate(resolve))
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined()
acquisition.release()
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
})
it('releasing before tools ever loads disposes the pending fiber without registering', async () => {
const ctx = new Context()
const acquisition = acquireStructuredRuntime(ctx)
acquisition.release()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await new Promise(resolve => setImmediate(resolve))
// The disposed fiber never fires: nothing registers after the fact.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
})
it('attach/captured/detach manage per-agent state through the acquisition surface', async () => {
const { ctx, parent } = await setup([])
const acquisition = acquireStructuredRuntime(ctx)
expect(acquisition.captured(parent)).toBeUndefined()
acquisition.attach(parent, SCHEMA)
expect(acquisition.captured(parent)).toBeUndefined()
acquisition.detach(parent)
acquisition.detach(parent)
acquisition.release()
// That manual acquisition was the ONLY holder - release disposes.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
// Child disposed ⇒ its scoped registrations are gone.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
})
})
it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => {
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
const { ctx, parent } = await setup([])
// Hold the runtime open (run-scoped: nothing is registered otherwise) so
// the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL.
const hold = acquireStructuredRuntime(ctx)
const result = await ctx.tools.execute({
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
@@ -588,19 +476,17 @@ describe('in-process structured output', () => {
agent: parent,
})
expect(result.isError).toBe(true)
expect(JSON.stringify(result.content)).toContain('only available to subagents')
hold.release()
expect(result.error?.code).toBe('UNKNOWN_TOOL')
})
it('a structured_output call with NO calling agent at all is an isError', async () => {
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
const { ctx } = await setup([])
const hold = acquireStructuredRuntime(ctx)
const result = await ctx.tools.execute({
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
hold.release()
expect(result.error?.code).toBe('UNKNOWN_TOOL')
})
})

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
* 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

View File

@@ -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)
})
})
})

View File

@@ -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]) {

View File

@@ -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
}
/**

View File

@@ -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 {

View File

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

View File

@@ -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