fix(subagent): validate direct depth boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 10:48:20 +08:00
parent 875c3d62d8
commit d427478c44
13 changed files with 88 additions and 36 deletions

View File

@@ -8,7 +8,7 @@ The shared **in-process subagent run driver**. A library with no provider or imp
Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`):
1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed;
1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects a malformed `request.maxDepth`, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects cap overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed;
2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back;
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).

View File

@@ -21,6 +21,7 @@ import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deeps
import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
@@ -42,20 +43,26 @@ declare module '@deepseek-ai/dsh-agent' {
* (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
* in-process backends on every child they create so a nested spawn reads its
* parent's depth from `parent.options.subagentDepth` and the `depthLimit`
* capability can cap the tree. Merge-extensible field (the seam owns it; the
* loop neither sets nor reads it).
* capability can cap the tree. When present it is a non-negative safe
* integer. Merge-extensible field (the seam owns it; the loop neither sets
* nor reads it).
*/
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0).
* Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0),
* rejecting a malformed stored value instead of letting it disable comparison.
* @param agent - the agent whose options may carry `subagentDepth`.
* @returns 0 for a top-level agent, its parent's depth + 1 for a subagent.
*/
export function depthOf(agent: Agent): number {
return agent.options.subagentDepth ?? 0
const depth = agent.options.subagentDepth ?? 0
if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
return depth
}
/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
@@ -139,6 +146,7 @@ export function startInProcessRun(
const inputPrompt = request.prompt
const inputAgentOptions = request.agentOptions
const inputSeed = options.seed
assertSubagentMaxDepth(inputMaxDepth)
const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter)
if (inputToolFilter !== undefined && toolFilter === undefined) {
throw new TypeError('subagent tool filter must be losslessly JSON-serializable')

View File

@@ -46,9 +46,41 @@ describe('depthOf', () => {
const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
expect(depthOf(withDepth)).toBe(3)
})
it.each([
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects subagentDepth=$label', ({ value }) => {
const agent = { options: { subagentDepth: value } } as unknown as Agent
expect(() => depthOf(agent)).toThrow('agent subagentDepth must be a non-negative safe integer')
})
})
describe('startInProcessRun', () => {
it.each([
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects maxDepth=$label before acquiring run ownership', async ({ value }) => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent,
maxDepth: value,
}, {})).toThrow('subagent maxDepth must be a non-negative safe integer')
})
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {

View File

@@ -19,6 +19,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple
| Member | Semantics |
|---|---|
| `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `assertSubagentMaxDepth(value)` | Shared runtime boundary for recursion caps. Accepts absence or a non-negative safe integer; rejects fractions, non-finite numbers, negative values, negative zero, and unsafe integers. The service, direct in-process driver, and model-facing config adapter all use it. |
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
| `list()` | Registered provider names (insertion order). |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |

View File

@@ -58,6 +58,25 @@ export type {
SubagentStopReasonMap,
} from './types.ts'
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* Undefined means the caller did not request a cap and is accepted. The
* service, direct in-process driver, and model-facing config adapter share this
* boundary so no entry path can turn a fractional or non-finite value into an
* ineffective limit.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -309,13 +328,7 @@ export class SubagentService extends Service {
const input = this.snapshotStartRequest(request)
const parent = input.parent
this.assertCapabilities(provider, input)
if (input.maxDepth !== undefined && (
!Number.isSafeInteger(input.maxDepth)
|| input.maxDepth < 0
|| Object.is(input.maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
assertSubagentMaxDepth(input.maxDepth)
if (input.persona !== undefined && typeof input.persona !== 'string') {
throw new TypeError('subagent persona must be a string')
}

View File

@@ -69,8 +69,9 @@ export interface SubagentStartRequest {
*/
outputSchema?: StructuredOutputSchema
/**
* Optional recursion cap (max delegation depth below this child). Requires
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
* Optional recursion cap (max delegation depth below this child). Must be a
* non-negative safe integer. Requires {@link SubagentCapabilities.depthLimit};
* rejected at start otherwise.
*/
maxDepth?: number
/**

View File

@@ -431,10 +431,14 @@ describe('SubagentService', () => {
})
it.each([
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a fraction', value: 1.5 },
{ label: 'a negative integer', value: -1 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects maxDepth=$label before the provider starts', async ({ value }) => {
const ctx = new Context()
await ctx.plugin(SubagentService)

View File

@@ -35,6 +35,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent'
@@ -119,17 +120,6 @@ export const Config: z<Config> = z.object({
maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
})
/** Reject a recursion cap that cannot represent an exact delegation depth. */
function assertMaxDepth(maxDepth: number | undefined): void {
if (maxDepth !== undefined && (
!Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new Error('tool-subagent: `maxDepth` must be a non-negative safe integer')
}
}
/**
* Flatten a child's final output blocks to text for the tool result. The child
* may return non-text blocks; this cut surfaces the text content (the common
@@ -202,7 +192,7 @@ export function providerWording(inherits: boolean): { description: string; promp
export function apply(ctx: Context, config: Config): void {
// Keep misconfiguration at plugin load even when a caller invokes apply()
// directly and bypasses Schemastery's natural/max metadata.
assertMaxDepth(config.maxDepth)
assertSubagentMaxDepth(config.maxDepth)
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
// explicit `toolFilter: {}` would otherwise pass the capability gate and
// kill every delegation later, in the child-setup `restrict({})` throw.

View File

@@ -494,6 +494,9 @@ describe('dsh-tool-subagent', () => {
})
it.each([
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a negative integer', value: -1 },
{ label: 'a fractional number', value: 1.5 },
{ label: 'negative zero', value: -0 },