fix(subagent): validate direct depth boundaries
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user