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