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:
@@ -709,11 +709,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreateAgentOptions',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'DiffCallView',
|
||||
@@ -929,7 +929,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionHeader',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionId',
|
||||
|
||||
@@ -625,6 +625,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
|
||||
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
|
||||
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
|
||||
...loaded.meta.delegationDepth === undefined ? {} : { delegationDepth: loaded.meta.delegationDepth },
|
||||
},
|
||||
})
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
|
||||
@@ -411,7 +411,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
@@ -423,7 +423,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const forked = ctx1.sessions.create(SessionId('forked-sess'), {
|
||||
seed,
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
|
||||
})
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -447,6 +447,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
expect(a2.session.header.seedLength).toBe(seed.length)
|
||||
// The recursion budget survives resume — a dropped depth would let a
|
||||
// resumed child delegate as if it were top-level.
|
||||
expect(a2.session.header.delegationDepth).toBe(1)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -46,15 +46,21 @@ export interface CreateAgentOptions {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, and the `seedLength` seed boundary. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength` fields of
|
||||
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
|
||||
* recursion budget. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it). This is durable session data,
|
||||
* so the session boundary validates and snapshots it before asynchronous
|
||||
* setup begins.
|
||||
*/
|
||||
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
* primitive). When present, the factory creates the session with this event
|
||||
|
||||
@@ -38,7 +38,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
@@ -73,7 +73,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
if (record.delegationDepth !== undefined
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -650,6 +654,7 @@ export class SessionStore extends Service {
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface SessionHeader {
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
/**
|
||||
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
|
||||
* for a subagent child. Persisted so a recursion budget survives restart and
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +75,7 @@ export interface CreateSessionOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -882,6 +882,19 @@ describe('SessionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches delegationDepth from meta to the header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('delegated-child'), {
|
||||
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
|
||||
})
|
||||
expect(session.header).toMatchObject({
|
||||
id: 'delegated-child',
|
||||
parentSession: 'parent',
|
||||
delegationDepth: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-JSON and invalid scalar session metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -893,6 +906,9 @@ describe('SessionStore', () => {
|
||||
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
|
||||
]
|
||||
|
||||
for (const [index, { meta, error }] of cases.entries()) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface HeaderLine {
|
||||
cwd?: string
|
||||
parentSession?: SessionId
|
||||
seedLength?: number
|
||||
delegationDepth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
...header.cwd !== undefined ? { cwd: header.cwd } : {},
|
||||
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
|
||||
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
...header.delegationDepth !== undefined ? { delegationDepth: header.delegationDepth } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
...line.cwd !== undefined ? { cwd: line.cwd } : {},
|
||||
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
|
||||
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
|
||||
...line.delegationDepth !== undefined ? { delegationDepth: line.delegationDepth } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,14 +253,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
*/
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
created_at = excluded.created_at,
|
||||
cwd = excluded.cwd,
|
||||
parent_session = excluded.parent_session,
|
||||
seed_length = excluded.seed_length
|
||||
seed_length = excluded.seed_length,
|
||||
delegation_depth = excluded.delegation_depth
|
||||
`).run(
|
||||
meta.id,
|
||||
meta.version,
|
||||
@@ -268,6 +269,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
meta.cwd ?? null,
|
||||
meta.parentSession ?? null,
|
||||
meta.seedLength ?? null,
|
||||
meta.delegationDepth ?? null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 4
|
||||
export const SCHEMA_VERSION = 5
|
||||
|
||||
/**
|
||||
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
|
||||
@@ -31,6 +31,7 @@ export interface SessionRow {
|
||||
cwd: string | null
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
delegation_depth: number | null
|
||||
}
|
||||
|
||||
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
|
||||
@@ -83,9 +84,10 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
delegation_depth INTEGER
|
||||
) STRICT
|
||||
`)
|
||||
db.exec(`
|
||||
@@ -116,6 +118,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
...row.cwd !== null ? { cwd: row.cwd } : {},
|
||||
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
|
||||
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
|
||||
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(4)
|
||||
expect(SCHEMA_VERSION).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,27 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the delegation depth through persistence', async () => {
|
||||
// A subagent child's recursion budget lives in its header; a reload that
|
||||
// dropped it would reset the child to top-level and un-bound maxDepth
|
||||
// (JSONL stores it in the header line; SQLite uses `delegation_depth`).
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId('delegated-child'), {
|
||||
meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 },
|
||||
})
|
||||
send(session, oneTurnLog())
|
||||
await ctx.parallel('session/flush', session)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child'))
|
||||
expect(loaded.meta.delegationDepth).toBe(2)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user