test(subagent): close continuable coverage and drop unreachable guards
Restores the one-shot settleRun coverage in its own file beside the helper, covers fork's seed contribution, the post-transfer rollback, the descriptor model route on cold resume, manager-unload drain, and a failing teardown branch. Removes three redundant checks the surrounding contracts already own: the duplicate-Activation and live-id pre-checks (AgentRegistry.enter is the authoritative collision boundary) and a rollback lifecycle edge that could never publish because the epoch had no start edge.
This commit is contained in:
@@ -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 docs/core-data-structures/subagent.md
|
||||
subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f
|
||||
subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c
|
||||
subagent.md: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900
|
||||
subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3
|
||||
@@ -4,24 +4,25 @@
|
||||
|
||||
subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。
|
||||
|
||||
接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
|
||||
|
||||
## 两类能力,两种发现方式
|
||||
|
||||
提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。
|
||||
提供方通过一个静态描述符公布其**启动时**特性,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-seam-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider)。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service before delegating to
|
||||
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
|
||||
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
|
||||
* degradation" rule). These static flags cover features needed before a run exists; runtime
|
||||
* capabilities are optional methods whose presence is the capability — confirmed live steering
|
||||
* is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each
|
||||
* flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to
|
||||
* `maxDepth`; the other names match.
|
||||
* degradation" rule). These flags describe the ONE-SHOT
|
||||
* {@link SubagentProvider.start} path, where the provider composes the child;
|
||||
* continuable children are composed by the continuation manager itself and are
|
||||
* gated by {@link SubagentProvider.prepareContinuable} instead. Each flag
|
||||
* corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit`
|
||||
* to `maxDepth`; the other names match.
|
||||
*/
|
||||
interface SubagentCapabilities {
|
||||
readonly outputSchema: boolean
|
||||
@@ -31,16 +32,16 @@ interface SubagentCapabilities {
|
||||
}
|
||||
```
|
||||
|
||||
## 启动请求
|
||||
## 单次启动请求
|
||||
|
||||
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* What a caller asks for when starting a subagent. The tool layer builds this
|
||||
* from the model's `{ description, prompt }` plus its own config; the service
|
||||
* validates {@link SubagentCapabilities} against the named provider and
|
||||
* resolves a {@link SubagentProviderStartRequest} for dispatch.
|
||||
* What a caller asks for when starting a ONE-SHOT subagent. The tool layer
|
||||
* builds this from the model's `{ description, prompt }` plus its own config;
|
||||
* the service validates {@link SubagentCapabilities} against the named provider
|
||||
* before dispatching to {@link SubagentProvider.start}.
|
||||
*/
|
||||
interface SubagentStartRequest {
|
||||
/** Content delivered as the child's user message. */
|
||||
@@ -94,31 +95,41 @@ interface SubagentStartRequest {
|
||||
|
||||
`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。
|
||||
|
||||
提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。
|
||||
提供方接收的正是此请求:单次委派不含由服务解析的继续执行状态,因为可继续子 agent 绝不会到达 `SubagentProvider.start()`。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Provider-facing start request after the service resolves optional
|
||||
* continuation state. Ordinary callers use {@link SubagentStartRequest}; only
|
||||
* the Task-backed continuation path can attach a stable child identity and
|
||||
* durable descriptor.
|
||||
*/
|
||||
interface SubagentProviderStartRequest extends SubagentStartRequest {
|
||||
/**
|
||||
* Continuable-child state resolved by `ctx.subagents` before provider dispatch.
|
||||
* The provider MUST publish exactly `sessionId` as the child identity
|
||||
* instead of allocating one internally, and MUST append the snapshotted,
|
||||
* model-hidden `subagent/descriptor` before the initial prompt is admitted.
|
||||
* Requires {@link SubagentProvider.resume} (the
|
||||
* continuation capability); the service rejects the request otherwise.
|
||||
*/
|
||||
readonly continuation?: SubagentContinuation | undefined
|
||||
}
|
||||
## 可继续子 agent 与激活
|
||||
|
||||
**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。
|
||||
|
||||
```text
|
||||
persisted Session
|
||||
-> optional live Activation
|
||||
-> one retained AgentHandle
|
||||
-> Agent inbox as the only turn FIFO
|
||||
-> zero or more owned child Activations
|
||||
```
|
||||
|
||||
## 可继续子 agent 与提供方恢复
|
||||
`SubagentService.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始 prompt。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose 任何已创建的 handle,回滚 Activation 与父级所有权。
|
||||
|
||||
**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。
|
||||
`SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态:
|
||||
|
||||
| Activation 状态 | 发送方 | `followup` |
|
||||
|---|---|---|
|
||||
| `running` | parent 或 user | 在同一 Activation 中入队 |
|
||||
| `waiting` | parent 或 user | 唤醒同一 Activation |
|
||||
| 无 Activation | parent 或 user | 冷恢复一个新的 Activation |
|
||||
|
||||
`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。
|
||||
|
||||
Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。
|
||||
|
||||
授权由受信任的宿主交互或一个确切的实时 Agent 工具上下文提供。仅当已认证的 Agent 是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级时,才会准入 parent 变体;只有受信任的宿主适配器才能提供 user 授权。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限——可选的面向模型工具使用 `CoordinatorMessageSource`,宿主适配器则使用 `{ kind: 'user' }`。user 授权可以在不加载子 agent 历史父级的情况下冷恢复它。
|
||||
|
||||
对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。
|
||||
|
||||
每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
|
||||
|
||||
只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。
|
||||
|
||||
```ts type-equiv
|
||||
/** Attribution for a model coordinator's follow-up to one of its children. */
|
||||
@@ -131,76 +142,90 @@ interface CoordinatorMessageSource {
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Options for following up with one continuable child.
|
||||
* Who authorizes one continuable-subagent operation. Authority comes from a
|
||||
* trusted host interaction or an exact live Agent tool context; durable
|
||||
* {@link MessageSource} provenance never authorizes delivery.
|
||||
*/
|
||||
type SubagentAuthority =
|
||||
/** The exact live parent Agent whose tool context is making the call. */
|
||||
| { readonly kind: 'parent'; readonly agent: Agent }
|
||||
/** A trusted host adapter acting for the human user. */
|
||||
| { readonly kind: 'user' }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Options for following up with one continuable child. */
|
||||
interface SubagentFollowupOptions {
|
||||
/** Durable attribution retained on either live or resumed delivery. */
|
||||
/** Durable attribution retained on the delivered message; it grants no authority. */
|
||||
readonly source: MessageSource
|
||||
/** Caller cancellation for a live-delivery admission wait. */
|
||||
/** Caller cancellation, owning the operation only until inbox acceptance. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* How a continuable follow-up was routed:
|
||||
* `steered` joined the running activation's existing Task without creating a
|
||||
* Task of its own; `started` created a fresh Task that cold-resumes the
|
||||
* durable child with the content. Failure is an exception, never a result —
|
||||
* undelivered content throws.
|
||||
*/
|
||||
type SubagentFollowupResult =
|
||||
| { readonly route: 'steered'; readonly taskId: TaskId }
|
||||
| { readonly route: 'started'; readonly taskId: TaskId }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The resolved continuable-child identity and durable composition record the
|
||||
* service attaches before provider dispatch.
|
||||
*/
|
||||
interface SubagentContinuation {
|
||||
/** Service-allocated stable child session id, published verbatim. */
|
||||
readonly sessionId: SessionId
|
||||
/** Snapshotted descriptor persisted in the child log for cold resume. */
|
||||
readonly descriptor: SubagentDescriptorData
|
||||
/** Identities returned once a continuable child accepted its initial prompt. */
|
||||
interface ContinuableStart {
|
||||
/** The durable child session id, stable across activations. */
|
||||
readonly childId: SessionId
|
||||
/** The accepted initial prompt's inbox message id. */
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Provider-facing request for reconstructing a persisted continuable child.
|
||||
* The continuation manager loads the child log, folds and authorizes its
|
||||
* descriptor, then privately dispatches this resolved request to
|
||||
* {@link SubagentProvider.resume}. The provider reconstructs the declared
|
||||
* composition under the live parent's scope and drives one turn with `prompt`.
|
||||
* The public residency state of one continuable child, derived from Agent
|
||||
* quiescence and the owned-child set rather than a second state machine:
|
||||
* `running` — the Agent has an active admission or turn, or waking inbox work;
|
||||
* `waiting` — the Agent is quiescent but still owns undisposed children;
|
||||
* `settled` — quiescent with every owned child disposed, so the manager
|
||||
* disposes the `AgentHandle` and removes the Activation.
|
||||
*/
|
||||
interface SubagentProviderResumeRequest {
|
||||
/** The persisted child session id to resume. */
|
||||
type ActivationState = 'running' | 'waiting' | 'settled'
|
||||
```
|
||||
|
||||
提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* What the continuation manager asks a provider for while materializing one
|
||||
* continuable child's FIRST activation. The manager has already reserved the
|
||||
* durable child identity and owns every later operation, so this request
|
||||
* carries only what distinguishes a fresh child from one seeded with parent
|
||||
* history.
|
||||
*/
|
||||
interface ContinuableCreateRequest {
|
||||
/** The reserved durable child session id, for provider diagnostics. */
|
||||
readonly sessionId: SessionId
|
||||
/** The follow-up message that starts the resumed activation's turn. */
|
||||
readonly prompt: ContentBlock[]
|
||||
/** Attribution retained when the follow-up becomes the resumed turn's user-role message. */
|
||||
readonly source: MessageSource
|
||||
/**
|
||||
* The live parent agent — the direct parent recorded in the persisted child
|
||||
* header. In-process backends reconstruct the child under this agent's
|
||||
* currently loaded scope.
|
||||
*/
|
||||
/** The delegating parent agent whose history a seeding provider reads. */
|
||||
readonly parent: Agent
|
||||
/**
|
||||
* Activation-owned cancellation signal, created before descriptor lookup.
|
||||
* Same pre/post-publication contract as {@link SubagentStartRequest.signal}:
|
||||
* an abort before publication rejects after rollback quiescence, and an
|
||||
* abort afterward cancels the published child turn.
|
||||
* Caller cancellation, which owns preparation only until the manager accepts
|
||||
* the initial prompt into the child's inbox.
|
||||
*/
|
||||
readonly signal: AbortSignal
|
||||
/** The folded durable descriptor whose composition the provider reconstructs. */
|
||||
readonly descriptor: SubagentDescriptorData
|
||||
}
|
||||
```
|
||||
|
||||
描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A provider's detached contribution to one continuable child's creation. This
|
||||
* is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt
|
||||
* delivery, result, disposal, or resume operation, because the continuation
|
||||
* manager owns the child's whole lifecycle after preparation.
|
||||
*/
|
||||
interface ContinuableCreateSpec {
|
||||
/**
|
||||
* Completed-turn prefix of the parent's log to seed the child session with,
|
||||
* or absent for a fresh child. Same durable contract as
|
||||
* `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced.
|
||||
*/
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
}
|
||||
```
|
||||
|
||||
描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
|
||||
## 终态结果:`SubagentResult`
|
||||
|
||||
@@ -249,17 +274,18 @@ interface SubagentStopReasonMap {
|
||||
}
|
||||
```
|
||||
|
||||
<a id="a-live-run-subagentrun"></a>
|
||||
## 单次 run:`SubagentRun`
|
||||
|
||||
## 活跃 run:`SubagentRun`
|
||||
|
||||
`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。
|
||||
`SubagentRun` 是消费方持有的、指向一个就绪单次子 agent 的句柄——一次可 dispose 的前台委派,只有一个结果,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。run 没有 steering,也没有 resume:可继续对话根本没有 run,因为继续执行管理器直接持有它们的 `AgentHandle`,并通过子 agent 自己的收件箱为每个轮次排序。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Child handle returned only after readiness. Consumers await {@link result} and must always
|
||||
* {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime
|
||||
* capability discovery; narrow their presence before calling.
|
||||
* ONE-SHOT child handle returned only after readiness. Consumers await
|
||||
* {@link result} and must always {@link dispose} to cancel remaining work and
|
||||
* reach quiescence. A run is one disposable foreground delegation with one
|
||||
* result; continuable conversations have no run — the continuation manager
|
||||
* holds their `AgentHandle` directly and orders every turn through the child's
|
||||
* own inbox.
|
||||
*/
|
||||
interface SubagentRun {
|
||||
/**
|
||||
@@ -278,10 +304,8 @@ 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. 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.
|
||||
* `isError` tool result. Rejects on an infrastructure fault the seam cannot
|
||||
* represent as a stop reason.
|
||||
*/
|
||||
readonly result: Promise<SubagentResult>
|
||||
/**
|
||||
@@ -289,25 +313,16 @@ interface SubagentRun {
|
||||
* Idempotent.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
* OPTIONAL (confirmed live-steering capability): submit additional content
|
||||
* to the active child and fulfill only after a committed request snapshot
|
||||
* admits it. Rejects when terminal policy, cancellation, disposal, or a lost
|
||||
* settlement race prevents admission; it never falls through to a queued
|
||||
* untracked turn or cold resume. A run represents one disposable activation,
|
||||
* so resuming a settled child goes through {@link SubagentProvider.resume}.
|
||||
* `source` is retained on the admitted steering message without changing its
|
||||
* user role in model history.
|
||||
*/
|
||||
steer?(content: ContentBlock[], source: MessageSource): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。
|
||||
本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。
|
||||
|
||||
<a id="the-provider-seam-subagentprovider"></a>
|
||||
|
||||
## 提供方 seam:`SubagentProvider`
|
||||
|
||||
每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。
|
||||
每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -327,33 +342,37 @@ interface SubagentProvider {
|
||||
*/
|
||||
readonly inheritsParentContext: boolean
|
||||
/**
|
||||
* Establish a child and return its handle only after publication. The
|
||||
* service has already validated that every requested start-time capability
|
||||
* is supported, so an implementation may assume e.g. `request.maxDepth` is
|
||||
* honorable when present. If setup fails or `request.signal` aborts before
|
||||
* fulfillment, the provider owns and cleans all partial resources before this
|
||||
* promise rejects. Ownership transfers to the caller only on fulfillment.
|
||||
* Establish a ONE-SHOT child and return its handle only after publication.
|
||||
* The service has already validated that every requested start-time
|
||||
* capability is supported, so an implementation may assume e.g.
|
||||
* `request.maxDepth` is honorable when present. If setup fails or
|
||||
* `request.signal` aborts before fulfillment, the provider owns and cleans
|
||||
* all partial resources before this promise rejects. Ownership transfers to
|
||||
* the caller only on fulfillment.
|
||||
*/
|
||||
start(request: SubagentProviderStartRequest): Promise<SubagentRun>
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
/**
|
||||
* OPTIONAL (continuation capability): reconstruct a persisted continuable
|
||||
* child from its own transcript and declared descriptor, drive one
|
||||
* follow-up turn, and return a fresh run. Method presence is the capability
|
||||
* — the service rejects continuable starts and cold-resume dispatch on
|
||||
* providers without it. Same publication contract as {@link start}: if
|
||||
* reconstruction fails or `request.signal` aborts before fulfillment, the
|
||||
* provider rolls its creation transaction back to quiescence before
|
||||
* rejecting; after fulfillment the same signal cancels the published run.
|
||||
* OPTIONAL (continuable-creation capability): contribute the detached
|
||||
* creation inputs that distinguish this provider's continuable children —
|
||||
* today only whether the child session is seeded with parent history. Method
|
||||
* presence IS the capability: the service rejects continuable starts on
|
||||
* providers without it, while a provider that has it may still serve
|
||||
* ordinary one-shot delegations.
|
||||
*
|
||||
* This is the provider's ONLY participation in a continuable child. The
|
||||
* continuation manager owns identity reservation, composition, Agent
|
||||
* creation, prompt delivery, cold resume, ownership, and disposal, so a
|
||||
* provider never sees the child's Agent, handle, turns, or teardown.
|
||||
*/
|
||||
resume?(request: SubagentProviderResumeRequest): Promise<SubagentRun>
|
||||
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
|
||||
}
|
||||
```
|
||||
|
||||
提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。
|
||||
提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。
|
||||
|
||||
## 进程内后端:深度与种子
|
||||
|
||||
spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇:
|
||||
spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose;而可继续子 agent 则由继续执行管理器通过其自己的 activation-owner 作用域创建。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇:
|
||||
|
||||
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
|
||||
- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
|
||||
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
|
||||
- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
|
||||
@@ -211,6 +211,35 @@ describe('dsh-subagent-fork', () => {
|
||||
expect(ctx.subagents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('contributes the completed-turn prefix as a continuable child\'s seed', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')])
|
||||
const provider = ctx.subagents.getProvider('fork')!
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// Before any completed parent turn there is nothing to inherit, so the
|
||||
// child starts fresh rather than carrying an empty seed.
|
||||
const fresh = await provider.prepareContinuable!({
|
||||
sessionId: SessionId('continuable-fresh'),
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
expect(fresh.seed).toBeUndefined()
|
||||
|
||||
// Complete one parent turn, then the prefix is captured once at creation.
|
||||
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const seeded = await provider.prepareContinuable!({
|
||||
sessionId: SessionId('continuable-seeded'),
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
expect(seeded.seed).toBeDefined()
|
||||
const lastSeeded = seeded.seed!.at(-1)
|
||||
// The seed ends at a completed turn, so it replays as a valid child log.
|
||||
expect(lastSeeded?.type).toBe('turn/end')
|
||||
expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index))
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in fork).toBe(false)
|
||||
expect(fork.name).toBe('subagent-fork')
|
||||
|
||||
@@ -113,10 +113,10 @@ export interface ActivationObserver {
|
||||
/**
|
||||
* Publish the terminal edge exactly once. An epoch that never became resident
|
||||
* emits nothing, because it has no start edge to pair.
|
||||
* @param child - the child agent whose final output the edge reports, if any.
|
||||
* @param child - the child agent whose final output the edge reports.
|
||||
* @param failure - the teardown or durability failure, or `undefined` on success.
|
||||
*/
|
||||
settle(child: Agent | undefined, failure: unknown): void
|
||||
settle(child: Agent, failure: unknown): void
|
||||
}
|
||||
|
||||
/** Hooks the manager needs from the owning service. */
|
||||
@@ -243,24 +243,6 @@ export class SubagentContinuationManager {
|
||||
}.bind(this), 'subagents.continuations()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this manager still admits new materialization and delivery. Host
|
||||
* teardown closes admission synchronously through {@link enterDraining}.
|
||||
* @returns true once draining began.
|
||||
*/
|
||||
get isDraining(): boolean {
|
||||
return this.draining
|
||||
}
|
||||
|
||||
/**
|
||||
* Close admission synchronously: reject new creation, cold resume, and
|
||||
* delivery so a host can drain the live Activation forest without racing new
|
||||
* work. Idempotent.
|
||||
*/
|
||||
enterDraining(): void {
|
||||
this.draining = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one durable child's live residency state.
|
||||
* @param childId - the durable child session id.
|
||||
@@ -384,7 +366,9 @@ export class SubagentContinuationManager {
|
||||
* @throws an aggregate error when any branch failed to release.
|
||||
*/
|
||||
async drain(): Promise<void> {
|
||||
this.enterDraining()
|
||||
// Close admission synchronously before the first await, so no new creation,
|
||||
// cold resume, or delivery can race the snapshot below.
|
||||
this.draining = true
|
||||
// Snapshot roots after closing admission: a root is an Activation no live
|
||||
// Activation owns, so disposing roots recurses child-first into the forest.
|
||||
const owned = new Set<SessionId>()
|
||||
@@ -500,18 +484,10 @@ export class SubagentContinuationManager {
|
||||
signal: AbortSignal
|
||||
}): Promise<Activation> {
|
||||
const { childId, provider, parent } = inputs
|
||||
if (this.activations.has(childId)) {
|
||||
throw new SubagentError(
|
||||
`subagent "${childId}" already has a live activation; the message was not delivered`,
|
||||
'ACTIVATION_CONFLICT',
|
||||
)
|
||||
}
|
||||
if (this.ctx.agents.get(childId) !== undefined) {
|
||||
throw new SubagentError(
|
||||
`subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
|
||||
'OWNERSHIP_CONFLICT',
|
||||
)
|
||||
}
|
||||
// No id pre-check here: the child lock serializes each durable child, both
|
||||
// callers reach this only after confirming no Activation exists, and
|
||||
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
@@ -535,7 +511,7 @@ export class SubagentContinuationManager {
|
||||
} catch (error: unknown) {
|
||||
// Agent creation provides rollback before handle transfer, so nothing
|
||||
// outlives this rejection; report the epoch that never became resident.
|
||||
observer.settle(undefined, error)
|
||||
// No start edge was published, so this epoch has no lifecycle to close.
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -558,16 +534,11 @@ export class SubagentContinuationManager {
|
||||
} catch (error: unknown) {
|
||||
// Roll the transfer back completely: the Activation leaves the map, the
|
||||
// parent's ownership membership is released, and the created handle is
|
||||
// disposed before this rejection surfaces.
|
||||
// disposed before this rejection surfaces. No lifecycle edge is published,
|
||||
// because `observer.start()` below has not run for this epoch.
|
||||
this.activations.delete(childId)
|
||||
this.releaseOwnership(childId)
|
||||
activation.disposal = (async () => {
|
||||
try {
|
||||
await handle.dispose()
|
||||
} finally {
|
||||
observer.settle(handle.agent, error)
|
||||
}
|
||||
})()
|
||||
activation.disposal = handle.dispose()
|
||||
await activation.disposal.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ export class SubagentService extends Service {
|
||||
started = true
|
||||
this.emitLifecycle('subagent/start', identity, parent)
|
||||
},
|
||||
settle: (child: Agent | undefined, failure: unknown): void => {
|
||||
settle: (child: Agent, failure: unknown): void => {
|
||||
// A failure before residency has no start edge to pair, and inventing
|
||||
// one would report a lifecycle the child never had.
|
||||
if (settled || !started) return
|
||||
@@ -462,9 +462,10 @@ export class SubagentService extends Service {
|
||||
/**
|
||||
* The child's last assistant message content, for one Activation's terminal
|
||||
* lifecycle edge. Absent when no assistant message reached the log.
|
||||
* @param child - the settling child agent whose log is read.
|
||||
* @returns its final assistant content, or `undefined` when it produced none.
|
||||
*/
|
||||
function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined {
|
||||
if (child === undefined) return undefined
|
||||
function lastAssistantOutput(child: Agent): ContentBlock[] | undefined {
|
||||
const message = child.session.events.findLast(
|
||||
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
|
||||
)
|
||||
|
||||
@@ -630,13 +630,181 @@ describe('continuable public surface', () => {
|
||||
})
|
||||
|
||||
describe('continuable errors', () => {
|
||||
it('rejects a second live Activation for the same durable child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
// Occupy the id with an unmanaged live Agent.
|
||||
const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' })
|
||||
await ctx.sessions.flush(squatter.session)
|
||||
await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello')))
|
||||
it('rejects a duplicate Activation at the agent registry collision boundary', async () => {
|
||||
const hold = Promise.withResolvers<void>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const found = ctx.agents.get(started.childId)
|
||||
expect(found).toBeDefined()
|
||||
return found!
|
||||
})
|
||||
// Drop the Activation without disposing the Agent, leaving the id live but
|
||||
// unmanaged. Materialization must not adopt it.
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, unknown> }
|
||||
}).continuations
|
||||
manager.activations.delete(started.childId)
|
||||
|
||||
await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello')))
|
||||
.rejects.toThrow(SubagentError)
|
||||
void parent
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
hold.resolve()
|
||||
})
|
||||
|
||||
it('rejects parent authority whose agent is no longer the live registry entry', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const found = ctx.agents.get(started.childId)
|
||||
expect(found).toBeDefined()
|
||||
return found!
|
||||
})
|
||||
// A stale parent reference: same id, not the exact live entry.
|
||||
const stale = { ...parent, id: parent.id } as unknown as Agent
|
||||
|
||||
await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale')))
|
||||
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
||||
void child
|
||||
})
|
||||
|
||||
it('rejects establishing a child under a parent whose disposal already began', async () => {
|
||||
const hold = Promise.withResolvers<void>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const found = ctx.agents.get(started.childId)
|
||||
expect(found).toBeDefined()
|
||||
return found!
|
||||
})
|
||||
|
||||
// Begin the parent Activation's teardown, then try to give it a child.
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
await expect(ctx.subagents.startContinuable(startSpec(child)))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
hold.resolve()
|
||||
await drained
|
||||
})
|
||||
|
||||
it('reports a failing branch after every branch settles, without pinning the rest', async () => {
|
||||
const hold = Promise.withResolvers<void>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('child done') },
|
||||
{ chunks: textResponse('grandchild'), gate: hold.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const found = ctx.agents.get(started.childId)
|
||||
expect(found).toBeDefined()
|
||||
return found!
|
||||
})
|
||||
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
|
||||
// Make the grandchild's own handle disposal reject: scope teardown failure
|
||||
// propagates, unlike a contained `agent/disposed` listener throw.
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { handle: { dispose: () => Promise<void> } }> }
|
||||
}).continuations
|
||||
const branch = manager.activations.get(grandchild.childId)!
|
||||
const realDispose = branch.handle.dispose.bind(branch.handle)
|
||||
branch.handle.dispose = async () => {
|
||||
await realDispose()
|
||||
throw new Error('grandchild reap failed')
|
||||
}
|
||||
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
hold.resolve()
|
||||
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
|
||||
// The other branch still released, and durable sessions survive.
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.meta.id).toBe(started.childId)
|
||||
})
|
||||
|
||||
it('rolls the transfer back when ownership registration fails after handle transfer', async () => {
|
||||
const hold = Promise.withResolvers<void>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('parent child'), gate: hold.promise },
|
||||
{ chunks: textResponse('unused') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const outer = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const found = ctx.agents.get(outer.childId)
|
||||
expect(found).toBeDefined()
|
||||
return found!
|
||||
})
|
||||
// Begin the would-be parent's disposal, then race a grandchild into it. The
|
||||
// handle transfers before ownership registration rejects, so the rollback
|
||||
// must leave no Activation and no live Agent behind.
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { disposal: Promise<void> | undefined }> }
|
||||
}).continuations
|
||||
const before = new Set(ctx.agents.list().map(agent => agent.id))
|
||||
manager.activations.get(outer.childId)!.disposal = Promise.resolve()
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(child)))
|
||||
.rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([])
|
||||
})
|
||||
hold.resolve()
|
||||
})
|
||||
|
||||
it('reapplies the descriptor model route on cold resume', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
...startSpec(parent),
|
||||
request: {
|
||||
prompt: message('routed work'),
|
||||
parent,
|
||||
agentOptions: { provider: 'mock', model: 'child-model' },
|
||||
},
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data)
|
||||
.toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' })
|
||||
|
||||
// The resumed Activation runs on the declared route, not the parent's.
|
||||
await followup(ctx, { kind: 'user' }, started.childId, message('again'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model')
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('drains without continuation services as a no-op', async () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(SubagentService)
|
||||
// No `ctx.agents`, so no manager was ever bound and nothing was materialized.
|
||||
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('unloading the manager drains its live activations', async () => {
|
||||
const hold = Promise.withResolvers<void>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
// Manager unload uses the same drain, so no child outlives its runtime.
|
||||
const disposal = serviceFiber.dispose()
|
||||
hold.resolve()
|
||||
await disposal
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settleRun } from '../src/index.ts'
|
||||
|
||||
describe('outcome mapping helpers', () => {
|
||||
it.each([
|
||||
['completed', { status: 'completed', output: 'partial' }],
|
||||
['aborted', { status: 'killed' }],
|
||||
['error', { status: 'failed', detail: 'error' }],
|
||||
['max-tokens', { status: 'failed', detail: 'max-tokens' }],
|
||||
['refusal', { status: 'failed', detail: 'refusal' }],
|
||||
['paused', { status: 'failed', detail: 'paused' }],
|
||||
] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => {
|
||||
const output = [{ type: 'text' as const, text: 'partial' }]
|
||||
await expect(settleRun({
|
||||
id: SessionId('child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output, stopReason: stopReason as never }),
|
||||
dispose: () => Promise.resolve(),
|
||||
})).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: SessionId('child-1'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
expect(completed).toEqual({ status: 'completed', output: 'ok' })
|
||||
expect(order).toEqual(['dispose', 'reported'])
|
||||
|
||||
// An infrastructure rejection still disposes and reports failed.
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: SessionId('child-2'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
|
||||
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-5'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
expect(bothFailed).toEqual({
|
||||
status: 'failed',
|
||||
detail: 'Error: result failed; dispose failed: Error: reap failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user