fix(subagent): harden continuable persistence

This commit is contained in:
Dudu-0223
2026-07-24 12:39:07 +08:00
committed by imccyu
parent bb8ea2be51
commit 1ab3cbf673
23 changed files with 412 additions and 56 deletions

View File

@@ -4,7 +4,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches
## Activation lifecycle
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent.
A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output.
`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target.

View File

@@ -116,6 +116,13 @@ export function runOutcome(result: SubagentResult): TaskOutcome {
}
}
/** Render infrastructure failure detail without hiding a durability diagnosis. */
function runFailureDetail(error: unknown): string {
return error instanceof HarnessError && error.code === 'DURABILITY_FAILED'
? error.message
: String(error)
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
@@ -127,7 +134,7 @@ export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
outcome = { status: 'failed', detail: runFailureDetail(error) }
}
try {
await run.dispose()

View File

@@ -16,7 +16,7 @@ import { TaskId } from '@deepseek-ai/dsh-tasks'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts'
@@ -685,16 +685,29 @@ describe('outcome mapping helpers', () => {
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full'
const durabilityFailed = await settleRun({
id: SessionId('child-3'),
localAgent: undefined,
result: Promise.reject(new HarnessError(
durabilityMessage,
'DURABILITY_FAILED',
{ cause: new Error('disk full') },
)),
dispose: () => Promise.resolve(),
})
expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage })
const disposeFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: SessionId('child-4'),
id: SessionId('child-5'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee
README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d
README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b
README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c

View File

@@ -12,9 +12,10 @@ The driver follows this sequence:
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. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior.
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
@@ -22,7 +23,7 @@ When the optional sandbox-policy or approval service is composed, the driver sna
## Cold resume
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start.
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start.
## Cancellation and ownership
@@ -30,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read.
## Spawn and fork inputs

View File

@@ -12,9 +12,10 @@
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时对于可继续请求还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时对于可继续请求还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次
5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
@@ -22,7 +23,7 @@
## 冷恢复
`resumeInProcessRun(request): Promise<SubagentRun>` 会在当前父级作用域下重建持久化的可继续子 agent`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript文本记录fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。
`resumeInProcessRun(request): Promise<SubagentRun>` 会在当前父级作用域下重建持久化的可继续子 agent`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript文本记录fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。
## 取消与所有权
@@ -30,7 +31,7 @@
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
运行公开严格的 `steer` 功能:同步`AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback否则会在运行结果读取后启动一个未被跟踪的轮次。
运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback否则会在运行结果读取后启动一个未被跟踪的轮次。
## Spawn 与 fork 输入

View File

@@ -11,8 +11,8 @@ import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent'
import type {
SubagentDescriptorData,
SubagentResult,
@@ -67,6 +67,9 @@ export interface InProcessRunOptions {
readonly seed?: SessionEvent[]
}
/** Whether one activation must prove its final state durable before success. */
type Durability = 'best-effort' | 'required'
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
@@ -168,7 +171,15 @@ export async function startInProcessRun(
signal: request.signal,
setup,
})
return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured)
return driveTurn(
handle,
request.signal,
request.prompt,
childId,
seedLength,
request.continuation === undefined ? 'best-effort' : 'required',
structured,
)
}
/**
@@ -203,14 +214,15 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis
// The result boundary is this activation's own work: everything already in
// the resumed transcript belongs to earlier turns.
const resumePoint = handle.agent.session.events.length
return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint)
return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required')
}
/**
* Drive one activation turn on a published child and wrap it as a run. The
* caller has already created or resumed the agent; this owns the
* signal-handoff race, the live abort listener, result collection past
* `boundary`, strict steering, and disposal.
* `boundary`, the continuable-run durability confirmation, strict steering,
* and disposal.
*/
function driveTurn(
handle: AgentHandle,
@@ -218,6 +230,7 @@ function driveTurn(
prompt: ContentBlock[],
childId: SessionId,
boundary: number,
durability: Durability,
structured?: StructuredAttachment,
): SubagentRun | Promise<never> {
const child = handle.agent
@@ -238,6 +251,17 @@ function driveTurn(
try {
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
await child.whenIdle()
if (durability === 'required') {
try {
await child.ctx.sessions.flush(child.session)
} catch (error: unknown) {
throw new SubagentError(
`subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`,
'DURABILITY_FAILED',
{ cause: error },
)
}
}
return readResult(
child,
boundary,

View File

@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { resumeInProcessRun, startInProcessRun } from '../src/index.ts'
@@ -38,6 +38,22 @@ function request(parent: Agent, signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
}
function continuableRequest(parent: Agent) {
const sessionId = SessionId('continuable-child')
return {
...request(parent),
continuation: {
sessionId,
descriptor: {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
agentProvider: 'mock',
agentModel: 'mock',
},
},
}
}
function text(blocks: readonly { type: string; text?: string }[]): string {
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
}
@@ -56,6 +72,59 @@ describe('startInProcessRun', () => {
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('requires a final durability checkpoint for a continuable child', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const failure = new Error('disk full')
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
throw failure
})
const run = await startInProcessRun(continuableRequest(parent), {})
const caught: unknown = await run.result.catch((error: unknown) => error)
expect(caught).toBeInstanceOf(SubagentError)
const durabilityError = caught as SubagentError
expect(durabilityError.code).toBe('DURABILITY_FAILED')
expect(durabilityError.cause).toBe(failure)
expect(durabilityError.message).toContain(
'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full',
)
expect(flushes).toBe(2)
await run.dispose()
})
it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
if (flushes === 1) throw new Error('temporary append failure')
})
const run = await startInProcessRun(continuableRequest(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(2)
await run.dispose()
})
it('keeps foreground runs best-effort when their turn checkpoint fails', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
throw new Error('disk full')
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(1)
await run.dispose()
})
it('reports the message-turn outcome when a later non-message turn completes during flush', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
let injected = false
@@ -201,13 +270,21 @@ describe('startInProcessRun', () => {
it('resumes without inventing undeclared agent model options', async () => {
const childId = SessionId('resumed-child')
let flushes = 0
const child = {
id: childId,
options: {},
session: new Session(childId),
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
ctx: {
sessions: {
flush: () => {
flushes++
return Promise.resolve()
},
},
} as unknown as Context,
send(): void {},
reserveTurnAdmission: () => undefined,
updateInbox: () => 'not-found',
@@ -238,6 +315,7 @@ describe('startInProcessRun', () => {
})
expect(resumedOptions).toEqual({})
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(flushes).toBe(1)
await run.dispose()
})

View File

@@ -49,7 +49,7 @@ Runtime features are optional methods whose presence is the capability check: `S
## The durable descriptor
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` recovers it from a loaded child log. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction.
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before provider dispatch; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction.
## Delegation depth
@@ -61,7 +61,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.

View File

@@ -71,6 +71,102 @@ export interface SubagentDescriptorInput {
readonly toolFilter?: ToolRestriction
}
const DESCRIPTOR_KEYS = new Set([
'version',
'provider',
'agentProvider',
'agentModel',
'persona',
'toolFilter',
])
const TOOL_FILTER_KEYS = new Set(['allow', 'deny'])
/** Whether a persisted JSON value is an object record. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Reject fields outside one versioned record's declared schema. */
function assertKnownKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, path: string): void {
const unknown = Object.keys(value).find(key => !keys.has(key))
if (unknown !== undefined) {
throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`)
}
}
/** Read one optional string field from a persisted descriptor record. */
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (typeof field !== 'string') {
throw new Error(`persisted subagent descriptor ${key} must be a string`)
}
return field
}
/** Read one optional string-array field from a persisted tool restriction. */
function optionalStringArray(value: Record<string, unknown>, key: string): string[] | undefined {
if (!Object.hasOwn(value, key)) return undefined
const field = value[key]
if (!Array.isArray(field)) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
const items: unknown[] = field
if (items.some(item => typeof item !== 'string')) {
throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`)
}
return items as string[]
}
/** Validate and reconstruct a persisted tool restriction. */
function parseToolFilter(value: unknown): ToolRestriction {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor toolFilter must be an object')
}
assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter')
const allow = optionalStringArray(value, 'allow')
const deny = optionalStringArray(value, 'deny')
if (allow === undefined && deny === undefined) {
throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny')
}
return {
...allow !== undefined ? { allow } : {},
...deny !== undefined ? { deny } : {},
}
}
/** Validate one persisted descriptor payload for the current runtime. */
function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined {
if (!isRecord(value)) {
throw new Error('persisted subagent descriptor payload must be an object')
}
const version = value['version']
if (typeof version !== 'number') {
throw new Error('persisted subagent descriptor version must be a number')
}
if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload')
const provider = value['provider']
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
const toolFilter = Object.hasOwn(value, 'toolFilter')
? parseToolFilter(value['toolFilter'])
: undefined
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider,
...agentProvider !== undefined ? { agentProvider } : {},
...agentModel !== undefined ? { agentModel } : {},
...persona !== undefined ? { persona } : {},
...toolFilter !== undefined ? { toolFilter } : {},
}
}
/**
* Validate and detach descriptor inputs into the durable payload, before any
* Task or provider work begins — the same detached lossless-JSON boundary the
@@ -105,12 +201,13 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
* @returns the descriptor, or `undefined` when the log has none or its
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not
* resumable by this runtime).
* @throws when a current-version persisted payload does not match its complete
* declared schema.
*/
export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined {
const event = events.find(
(candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor',
)
if (event === undefined) return undefined
if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
return event.data
return parseSubagentDescriptor(event.data)
}

View File

@@ -207,8 +207,10 @@ export interface SubagentRun {
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
* failure resolves with `stopReason: 'error'` so the consumer maps it to an
* `isError` tool result. Rejects only on an infrastructure fault the seam
* cannot represent as a stop reason.
* `isError` tool result. For a continuable activation, a completed result
* also means the provider confirmed the activation's final state durable.
* Rejects on an infrastructure fault the seam cannot represent as a stop
* reason, including a failed required durability checkpoint.
*/
readonly result: Promise<SubagentResult>
/**

View File

@@ -274,15 +274,68 @@ describe('SubagentService', () => {
})
describe('subagent descriptors', () => {
it('omits absent model selectors and rejects unsupported versions', () => {
expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({
const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({
type: 'subagent/descriptor',
data,
} as unknown as SessionEvent<'subagent/descriptor'>)
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ provider: 'spawn' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
})
const unsupported = {
type: 'subagent/descriptor',
data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' },
} as unknown as SessionEvent<'subagent/descriptor'>
expect(foldSubagentDescriptor([unsupported])).toBeUndefined()
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
provider: 'spawn',
agentProvider: 'deepseek',
agentModel: 'chat',
persona: 'reviewer',
toolFilter: { allow: ['read'], deny: ['bash'] },
}
expect(snapshotSubagentDescriptor({
provider: complete.provider,
agentProvider: complete.agentProvider,
agentModel: complete.agentModel,
persona: complete.persona,
toolFilter: complete.toolFilter,
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }),
])).toMatchObject({ toolFilter: { allow: ['read'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }),
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
])).toBeUndefined()
expect(() => snapshotSubagentDescriptor({
provider: 'spawn',
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
})).toThrow('not losslessly JSON-serializable')
})
it.each([
['string payload', 'invalid', 'payload must be an object'],
['null payload', null, 'payload must be an object'],
['array payload', [], 'payload must be an object'],
['missing version', { provider: 'spawn' }, 'version must be a number'],
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'],
['missing provider', { version: 1 }, 'provider must be a string'],
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'],
['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'],
['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'],
['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'],
['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
})
})