fix(session): persist the delegation depth in the session header

A subagent child's recursion depth lived only in runtime AgentOptions,
so a persisted child came back from resume counted as top-level and
maxDepth stopped binding after every restart. Add
SessionHeader.delegationDepth, round-trip it through the JSONL and
SQLite backends (SQLite schema v5), restore it on agent-loop resume,
and write it when the in-process backends create a child. The seam now
owns the shared depth vocabulary (delegationDepthOf): the persisted
header is authoritative and monotone — runtime options may deepen it
but never lower it.
This commit is contained in:
Yichen Jiang
2026-07-19 17:20:36 +08:00
parent e05e7a0870
commit 0d00106fa8
29 changed files with 229 additions and 106 deletions

View File

@@ -8,7 +8,7 @@ This package is the shared run driver for the two in-process providers. Spawn pa
The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one.
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
@@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`.
Depth enforcement is internal to `startInProcessRun`: it reads the parent depth via `delegationDepthOf` (the persisted `SessionHeader.delegationDepth` is authoritative; runtime `AgentOptions.subagentDepth` may deepen but never lower it, so a resumed child keeps its budget), treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. The child depth is written to the child header, so it survives persistence and resume.
## Structured output

View File

@@ -12,7 +12,7 @@ import type { Context } from 'cordis'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
@@ -24,27 +24,6 @@ export {
STRUCTURED_OUTPUT_INSTRUCTION,
} from './structured.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* @param agent - the agent whose options carry the depth.
* @returns its non-negative safe-integer depth.
*/
function depthOf(agent: Agent): number {
const depth = agent.options.subagentDepth
if (depth === undefined) return 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 starting a child would exceed the requested depth cap. */
class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
@@ -96,7 +75,7 @@ export async function startInProcessRun(
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const childDepth = depthOf(parent) + 1
const childDepth = delegationDepthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
@@ -133,6 +112,8 @@ export async function startInProcessRun(
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...seedLength > 0 ? { seedLength } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},

View File

@@ -58,6 +58,44 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('persists the child depth in its session header', async () => {
const { ctx, parent } = await setup([textResponse('child answer')])
const run = await startInProcessRun(request(parent), {})
await run.result
// The recursion budget is durable session data, not only runtime options —
// a depth that lived only in AgentOptions would reset to 0 on resume.
expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1)
await run.dispose()
})
it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => {
// The review-reproduced failure chain: a depth-1 child comes back from
// persistence with a fresh AgentOptions (no subagentDepth). Its header must
// stay authoritative, or maxDepth: 1 would let it delegate as top-level.
const { ctx } = await setup([textResponse('unused')])
const resumed = (await ctx.agents.create({
sessionId: SessionId('resumed-child'),
meta: { parentSession: SessionId('root'), delegationDepth: 1 },
agentOptions: { provider: 'mock', model: 'mock' },
signal: new AbortController().signal,
})).agent
await expect(startInProcessRun({ ...request(resumed), maxDepth: 1 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 2, maxDepth: 1 })
})
it('lets runtime options deepen but never lower the persisted depth', async () => {
const { ctx } = await setup([textResponse('unused')])
const parent = (await ctx.agents.create({
sessionId: SessionId('deep-parent'),
meta: { delegationDepth: 2 },
agentOptions: { provider: 'mock', model: 'mock', subagentDepth: 1 },
signal: new AbortController().signal,
})).agent
// Persisted 2 vs runtime 1: the child is depth 3, so maxDepth 2 rejects.
await expect(startInProcessRun({ ...request(parent), maxDepth: 2 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 3, maxDepth: 2 })
})
it('rejects invalid and exceeded depth before publication', async () => {
const { parent } = await setup([])
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
@@ -65,11 +103,11 @@ describe('startInProcessRun', () => {
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError' })
for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) {
const malformed = { options: { subagentDepth: value } } as unknown as Agent
const malformed = { options: { subagentDepth: value }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(malformed), {}))
.rejects.toThrow('agent subagentDepth must be a non-negative safe integer')
}
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
})

View File

@@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic
- `toolFilter` — apply the requested child tool restriction.
- `persona` — apply a per-child persona.
## Delegation depth
The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.

View File

@@ -57,6 +57,32 @@ export type {
SubagentStopReasonMap,
} from './types.ts'
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth, treating absence as top-level depth zero.
* The persisted session header is authoritative and monotone: runtime
* `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it —
* a resumed child arrives with fresh options, and counting it from zero would
* let it delegate as if it were top-level.
* @param agent - the agent whose header and options carry the depth.
* @returns its non-negative safe-integer depth.
*/
export function delegationDepthOf(agent: Agent): number {
const runtime = agent.options.subagentDepth
if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
// The header value was validated at the session boundary (creation and
// persistence load both construct through the store).
return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.