feat(subagent): catalog one-shot child sessions

This commit is contained in:
Dudu-0223
2026-07-27 16:58:37 +08:00
committed by Tianyi Cui
parent de6e572e51
commit 774ee34b9a
85 changed files with 1034 additions and 539 deletions

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/README.md
README.md: 0c93856bf973781254195b2f3869a54833bf83ac
README.zh.md: aa4c58d01582863e702404d1c8f3d32d3db7f534
README.md: df2582518e97bb38d124f8c33259c30b9d11d759
README.zh.md: d788f30f31d92c3fd919b62891e9b03624f455f7

View File

@@ -28,13 +28,13 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
| `list()` | Return provider names in insertion order. |
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct continuable children and per-child diagnostics in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
`SubagentStartRequest.label` is the short durable display label for a session-backed child. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
@@ -53,7 +53,7 @@ Continuable creation is the optional `SubagentProvider.prepareContinuable?()` me
## The durable descriptor
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before the child session exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before materialization; 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` (never captured for a continuable child). 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 record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the durable creation label, provider name, and lifecycle `mode`. A `one-shot` descriptor stops there; a `continuable` descriptor additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor 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. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime.
## Delegation depth
@@ -67,7 +67,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
`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.
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. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
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`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
## Continuable children and Activations
@@ -89,7 +89,9 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan without consulting the continuation manager, Agent registrations, Activations, or providers. It forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
Continuable Activations require final durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
## Model Experience
@@ -101,9 +103,10 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **ACP children remain one-shot** — an ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn.
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state.
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.

View File

@@ -28,13 +28,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
| `list()` | 按插入顺序返回提供方名称。 |
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出直接可继续 child 及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
`SubagentStartRequest.label` 是由会话支撑的 child 所使用的简短持久化显示标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
@@ -53,7 +53,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 持久化描述符
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在子 agent 会话存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在物化前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(可继续子 agent 从不捕获它)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个描述符,其中包含持久化创建标签、提供方名称与生命周期 `mode`。`one-shot` 描述符到此为止;`continuable` 描述符还会记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。
## 委派深度
@@ -67,7 +67,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
## 可继续子 agent 与 Activation
@@ -89,7 +89,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释只读、实时优先的扫描结果,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。它会把调用方的取消信号转发给可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
可继续 Activation 要求最终持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
## 模型体验
@@ -101,9 +103,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 已知限制与延期工作
- **ACP 子 agent 仍为一次性**:ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。
- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。
- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。

View File

@@ -65,7 +65,7 @@ export interface ContinuableStartSpec {
* The delegation request. The manager reserves the stable child id, resolves
* the durable descriptor, and composes the child itself.
*/
readonly request: Omit<SubagentStartRequest, 'signal' | 'outputSchema'>
readonly request: Omit<SubagentStartRequest, 'label' | 'signal' | 'outputSchema'>
/** Caller cancellation, owning the operation only until inbox acceptance. */
readonly signal: AbortSignal
}
@@ -298,6 +298,7 @@ export class SubagentContinuationManager {
const agentProvider = request.agentOptions?.provider ?? parent.options.provider
const agentModel = request.agentOptions?.model ?? parent.options.model
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: spec.provider,
label: spec.label,
...agentProvider !== undefined ? { agentProvider } : {},
@@ -587,7 +588,7 @@ export class SubagentContinuationManager {
// which may carry an ANCESTOR's descriptor when the parent is itself a
// continuable child.
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
if (descriptor === undefined) {
if (descriptor === undefined || descriptor.mode !== 'continuable') {
throw new SubagentError(
`subagent "${childId}" has no supported continuation state and cannot be resumed; `
+ 'do not retry send_message with this id',

View File

@@ -1,9 +1,9 @@
/**
* The durable continuable-child descriptor: the versioned, model-hidden
* `subagent/descriptor` session event that records a child's declared
* composition so a known child id can be cold-resumed after its run — and its
* process — are gone. Providers append it turn-enclosed in the child's initial
* turn; the continuation manager folds it back on resume.
* The durable subagent-child descriptor: the versioned, model-hidden
* `subagent/descriptor` session event that identifies every session-backed
* subagent and records whether it is one-shot or continuable. Continuable
* descriptors additionally preserve the declared composition required for
* cold resume. Providers append it turn-enclosed in the child's initial turn.
*
* The descriptor deliberately snapshots explicit fields rather than the
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
@@ -23,11 +23,11 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable declared composition of a continuable subagent child, appended
* once by the establishing provider inside the child's initial turn,
* before its first request. Log-only: it carries no `surfaceOp`, never
* enters model history, and the append-only log retains it when
* compaction replaces surface history.
* Durable identity and lifecycle mode of a session-backed subagent child,
* appended once by the establishing provider inside the child's initial
* turn, before its first request. Continuable records also carry their
* resumable composition. Log-only: it carries no `surfaceOp`, never enters
* model history, and survives compaction.
*/
'subagent/descriptor': SubagentDescriptorData
}
@@ -39,12 +39,14 @@ declare module '@deepseek-ai/dsh-session' {
* Supporting another composition input is a deliberate version change, never
* an implicit extra field.
*/
export const SUBAGENT_DESCRIPTOR_VERSION = 1
export const SUBAGENT_DESCRIPTOR_VERSION = 2
/** The `subagent/descriptor` event payload — a continuable child's declared composition. */
export interface SubagentDescriptorData {
/** Fields shared by every supported `subagent/descriptor` payload. */
interface SubagentDescriptorBase {
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
readonly version: number
/** Whether the child is a terminal one-shot run or a resumable conversation. */
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that established the child. */
readonly provider: string
/**
@@ -53,6 +55,16 @@ export interface SubagentDescriptorData {
* replaying parent tool results or exposing the child prompt.
*/
readonly label: string
}
/** A session-backed subagent that cannot be cold-resumed after its run. */
export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'one-shot'
}
/** A session-backed subagent whose declared composition supports cold resume. */
export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {
readonly mode: 'continuable'
/** Resolved child `agentOptions.provider`, when one was declared. */
readonly agentProvider?: string
/** Resolved child `agentOptions.model`, when one was declared. */
@@ -63,12 +75,29 @@ export interface SubagentDescriptorData {
readonly toolFilter?: ToolRestriction
}
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
export interface SubagentDescriptorInput {
/** The supported durable subagent identity and optional continuation composition. */
export type SubagentDescriptorData =
| OneShotSubagentDescriptorData
| ContinuableSubagentDescriptorData
/** Fields shared by descriptor snapshot inputs. */
interface SubagentDescriptorInputBase {
/** Whether the child is a terminal one-shot run or a resumable conversation. */
readonly mode: 'one-shot' | 'continuable'
/** The `ctx.subagents` provider name that will establish the child. */
readonly provider: string
/** The initial delegation's short `description`, the durable creation label. */
readonly label: string
}
/** Input for a one-shot child's durable identity. */
export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'one-shot'
}
/** Input for a continuable child's durable identity and resumable composition. */
export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase {
readonly mode: 'continuable'
/** Requested child `agentOptions.provider`. */
readonly agentProvider?: string
/** Requested child `agentOptions.model`. */
@@ -79,10 +108,20 @@ export interface SubagentDescriptorInput {
readonly toolFilter?: ToolRestriction
}
const DESCRIPTOR_KEYS = new Set([
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
export type SubagentDescriptorInput =
| OneShotSubagentDescriptorInput
| ContinuableSubagentDescriptorInput
const DESCRIPTOR_BASE_KEYS = [
'version',
'mode',
'provider',
'label',
] as const
const ONE_SHOT_DESCRIPTOR_KEYS = new Set(DESCRIPTOR_BASE_KEYS)
const CONTINUABLE_DESCRIPTOR_KEYS = new Set([
...DESCRIPTOR_BASE_KEYS,
'agentProvider',
'agentModel',
'persona',
@@ -155,7 +194,15 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
}
if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload')
const mode = value['mode']
if (mode !== 'one-shot' && mode !== 'continuable') {
throw new Error('persisted subagent descriptor mode must be "one-shot" or "continuable"')
}
assertKnownKeys(
value,
mode === 'one-shot' ? ONE_SHOT_DESCRIPTOR_KEYS : CONTINUABLE_DESCRIPTOR_KEYS,
'payload',
)
const provider = value['provider']
if (typeof provider !== 'string') {
throw new Error('persisted subagent descriptor provider must be a string')
@@ -164,6 +211,14 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
if (typeof label !== 'string') {
throw new Error('persisted subagent descriptor label must be a string')
}
if (mode === 'one-shot') {
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode,
provider,
label,
}
}
const agentProvider = optionalString(value, 'agentProvider')
const agentModel = optionalString(value, 'agentModel')
const persona = optionalString(value, 'persona')
@@ -172,6 +227,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
: undefined
return {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode,
provider,
label,
...agentProvider !== undefined ? { agentProvider } : {},
@@ -190,15 +246,32 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
* @returns the versioned, detached descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(
input: OneShotSubagentDescriptorInput,
): OneShotSubagentDescriptorData
/**
* Validate and detach a continuable descriptor input.
* @param input - the caller-collected continuable composition fields.
* @returns the versioned, detached continuable descriptor payload.
* @throws when a field is not losslessly JSON-serializable.
*/
export function snapshotSubagentDescriptor(
input: ContinuableSubagentDescriptorInput,
): ContinuableSubagentDescriptorData
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
const candidate: SubagentDescriptorData = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: input.mode,
provider: input.provider,
label: input.label,
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
...input.mode === 'continuable'
? {
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
...input.persona !== undefined ? { persona: input.persona } : {},
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
}
: {},
}
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
@@ -214,8 +287,8 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
* composition.
* @param events - the loaded child session events.
* @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).
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child cannot be
* classified by this runtime).
* @throws when a current-version persisted payload does not match its complete
* declared schema.
*/

View File

@@ -41,6 +41,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
ResolvedSubagentStartRequest,
SubagentCapabilities,
SubagentProvider,
SubagentRun,
@@ -60,12 +61,14 @@ import type {
} from './continuation.ts'
import { listChildren as listSubagentChildren } from './list-children.ts'
import type { SubagentListEntry } from './list-children.ts'
import { snapshotSubagentDescriptor } from './descriptor.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,
ContinuableCreateSpec,
ResolvedSubagentStartRequest,
SubagentCapabilities,
SubagentProvider,
SubagentResult,
@@ -79,7 +82,14 @@ export {
snapshotSubagentDescriptor,
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
export type {
ContinuableSubagentDescriptorData,
ContinuableSubagentDescriptorInput,
OneShotSubagentDescriptorData,
OneShotSubagentDescriptorInput,
SubagentDescriptorData,
SubagentDescriptorInput,
} from './descriptor.ts'
export { seedDescriptorTurn } from './descriptor-seed.ts'
export { SubagentError } from './error.ts'
export { settleRun } from './run-settlement.ts'
@@ -224,11 +234,11 @@ export class SubagentService extends Service {
}
/**
* Enumerate the parent's direct continuable children from the live-preferred
* session corpus without loading or resuming an Agent. Session query supplies
* lineage, candidate order, event reads, and live state; this service
* interprets descriptors, status, and per-child diagnostics without consulting
* Agent registrations, Activations, or providers.
* Enumerate the parent's direct session-backed subagents from the
* live-preferred session corpus without loading or resuming an Agent. Session
* query supplies lineage, candidate order, event reads, and live state; this
* service interprets descriptor mode, activity, and per-child diagnostics
* without consulting Agent registrations, Activations, or providers.
*
* The trace and exact descriptor read receive `signal`; the full event-list
* read has no signal parameter, so the scan rechecks cancellation around
@@ -293,7 +303,7 @@ export class SubagentService extends Service {
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events.
* @param name - the provider to use.
* @param request - child prompt, parent, signal, and optional capabilities.
* @param request - child label, prompt, parent, signal, and optional capabilities.
* @returns the ready holder-owned run.
*/
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
@@ -301,7 +311,13 @@ export class SubagentService extends Service {
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(request))
const descriptor = snapshotSubagentDescriptor({
mode: 'one-shot',
provider: name,
label: request.label,
})
const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))
}
/**

View File

@@ -1,7 +1,8 @@
/**
* Read-only interpretation of session-query lineage as durable subagent
* children. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state.
* Agent-registry, continuation-manager, or provider state. A child's
* descriptor distinguishes one-shot work from a continuable conversation.
*
* @module @deepseek-ai/dsh-subagent
*/
@@ -31,14 +32,15 @@ export type SubagentListEntry =
readonly id: SessionId
/** The durable creation label from the child's descriptor. */
readonly label: string
/** Lifecycle policy declared when the child was created. */
readonly mode: 'one-shot' | 'continuable'
/**
* Corpus snapshot status: `running` means the logical record is live in
* `ctx.sessions`; `complete` means it exists only in persistence and
* `send_message` may materialize another Activation. Neither encodes a durable
* outcome, and a listed `running` child may still reject delivery as an
* ownership conflict.
* Corpus snapshot activity: `running` means the logical record is live in
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
* encodes a durable outcome, and a continuable child may still reject
* delivery as an ownership conflict.
*/
readonly status: 'running' | 'complete'
readonly activity: 'running' | 'inactive'
}
| {
readonly kind: 'diagnostic'
@@ -54,7 +56,7 @@ export type SubagentListEntry =
}
/**
* Interpret one parent's direct session descendants as continuable subagents
* Interpret one parent's direct session descendants as session-backed subagents
* without loading or resuming an Agent.
* @param ctx - context carrying the optional session-query service.
* @param parentSessionId - parent session whose direct children are listed.
@@ -108,7 +110,7 @@ async function inspectChild(
try {
const records = await runListingQuery(() => query.listEvents(childId), signal)
// Only the child's own suffix: a fork seed may replay an ancestor's
// descriptor without making the fork itself a continuable subagent.
// descriptor without making the fork itself a subagent.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
@@ -141,7 +143,8 @@ async function inspectChild(
kind: 'child',
id: childId,
label: descriptor.label,
status: candidate.live ? 'running' : 'complete',
mode: descriptor.mode,
activity: candidate.live ? 'running' : 'inactive',
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)

View File

@@ -14,6 +14,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { SubagentDescriptorData } from './descriptor.ts'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -88,9 +89,12 @@ export interface SubagentCapabilities {
* 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}.
* and resolves the durable descriptor before dispatching to
* {@link SubagentProvider.start}.
*/
export interface SubagentStartRequest {
/** Short display label persisted with a session-backed child. */
readonly label: string
/** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
@@ -139,6 +143,15 @@ export interface SubagentStartRequest {
readonly persona?: string
}
/**
* Provider-facing one-shot request after {@link SubagentService.start} resolves
* the durable child descriptor.
*/
export interface ResolvedSubagentStartRequest extends SubagentStartRequest {
/** Detached descriptor a session-backed provider persists in the child log. */
readonly descriptor: SubagentDescriptorData
}
/**
* What the continuation manager asks a provider for while materializing one
* continuable child's FIRST activation. The manager has already reserved the
@@ -268,13 +281,13 @@ export interface SubagentProvider {
/**
* 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.
* capability is supported and resolved `request.descriptor`, so a
* session-backed implementation appends that descriptor inside the child's
* initial turn. 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: SubagentStartRequest): Promise<SubagentRun>
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
/**
* OPTIONAL (continuable-creation capability): contribute the detached
* creation inputs that distinguish this provider's continuable children —

View File

@@ -201,7 +201,9 @@ describe('SubagentService.startContinuable', () => {
const descriptor = loaded.events[descriptorIndex] as SessionEvent<'subagent/descriptor'>
expect(descriptor.data).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
agentProvider: 'mock',
agentModel: 'mock',
})
@@ -294,7 +296,9 @@ describe('SubagentService.startContinuable', () => {
expect(descriptor?.data).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
})
await drainManager(ctx)
})
@@ -326,7 +330,9 @@ describe('SubagentService.startContinuable', () => {
expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data)
.toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'child task',
toolFilter: { deny: ['noop'] },
})
await drainManager(ctx)
@@ -526,8 +532,9 @@ describe('SubagentService.followup residency routing', () => {
it('reports an unresumable child whose persisted log has no supported descriptor', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// A ONE-SHOT child persists a log but never seeds a descriptor.
// A one-shot child has durable identity but no supported continuation state.
const run = await ctx.subagents.start('spawn', {
label: 'one-shot work',
prompt: message('one-shot work'),
parent,
signal: testSignal,
@@ -774,6 +781,7 @@ describe('continuable durability and teardown', () => {
])
const { ctx, parent } = await setupWith(adapter)
const run = await ctx.subagents.start('spawn', {
label: 'one-shot task',
prompt: message('one-shot task'),
parent,
signal: testSignal,
@@ -1374,6 +1382,7 @@ describe('continuable public surface', () => {
it('keeps one-shot runs free of a steering capability', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
const run = await ctx.subagents.start('spawn', {
label: 'one-shot work',
prompt: message('one-shot work'),
parent,
signal: testSignal,

View File

@@ -98,7 +98,7 @@ function childEvents(descriptor: unknown): SessionEvent[] {
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, provider: 'spawn', label }
return { version, mode: 'continuable' as const, provider: 'spawn', label }
}
describe('SubagentService.listChildren', () => {
@@ -121,7 +121,7 @@ describe('SubagentService.listChildren', () => {
child.append('subagent/descriptor', descriptorPayload('query-only child'))
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
{ kind: 'child', id: childId, label: 'query-only child', status: 'running' },
{ kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', activity: 'running' },
])
})
@@ -132,15 +132,46 @@ describe('SubagentService.listChildren', () => {
)
})
it('lists a persisted continuable child as complete with its durable label', async () => {
it('lists a persisted continuable child as inactive with its durable label', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'summarize the doc')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'summarize the doc', status: 'complete' },
{ kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable', activity: 'inactive' },
])
})
it('lists one-shot and continuable children from the same trace', async () => {
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot child',
prompt: [{ type: 'text', text: 'finish once' }],
parent,
signal: new AbortController().signal,
})
const oneShotId = oneShot.id
await oneShot.result
await oneShot.dispose()
const continuableId = await startChild(ctx, parent, 'continuable child')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toHaveLength(2)
expect(entries).toContainEqual({
kind: 'child',
id: oneShotId,
label: 'one-shot child',
mode: 'one-shot',
activity: 'inactive',
})
expect(entries).toContainEqual({
kind: 'child',
id: continuableId,
label: 'continuable child',
mode: 'continuable',
activity: 'inactive',
})
})
it('accepts a persisted (non-live) parent target after restart', async () => {
const { ctx } = await setup([])
// A parent that exists only in persistence — the restart shape.
@@ -159,7 +190,7 @@ describe('SubagentService.listChildren', () => {
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'persisted parent case', status: 'complete' },
{ kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable', activity: 'inactive' },
])
})
@@ -197,8 +228,12 @@ describe('SubagentService.listChildren', () => {
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'child', id: settled, label: 'settled child', status: 'complete' })
expect(entries).toContainEqual({ kind: 'child', id: liveId, label: 'live child', status: 'running' })
expect(entries).toContainEqual({
kind: 'child', id: settled, label: 'settled child', mode: 'continuable', activity: 'inactive',
})
expect(entries).toContainEqual({
kind: 'child', id: liveId, label: 'live child', mode: 'continuable', activity: 'running',
})
})
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
@@ -217,7 +252,9 @@ describe('SubagentService.listChildren', () => {
}, events)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', status: 'complete' })
expect(entries).toContainEqual({
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive',
})
})
it('diagnoses an invalid child event surface as corrupt', async () => {
@@ -244,7 +281,7 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 7 }))
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
})
@@ -275,10 +312,15 @@ describe('SubagentService.listChildren', () => {
const { ctx, parent } = await setup([])
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
parentSession: parent.id,
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'not-mounted', label: 'orphan provider' }))
}, childEvents({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'not-mounted',
label: 'orphan provider',
}))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: foreign, label: 'orphan provider', status: 'complete' },
{ kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable', activity: 'inactive' },
])
})
@@ -393,8 +435,8 @@ describe('SubagentService.listChildren', () => {
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: plain, label: 'twin child', status: 'complete' },
{ kind: 'child', id: compacted, label: 'twin child', status: 'complete' },
{ kind: 'child', id: plain, label: 'twin child', mode: 'continuable', activity: 'inactive' },
{ kind: 'child', id: compacted, label: 'twin child', mode: 'continuable', activity: 'inactive' },
])
})
@@ -406,7 +448,7 @@ describe('SubagentService.listChildren', () => {
}, childEvents(descriptorPayload('grandchild')))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
{ kind: 'child', id: childId, label: 'direct child', mode: 'continuable', activity: 'inactive' },
])
})

View File

@@ -10,6 +10,7 @@ import SubagentService, {
SUBAGENT_DESCRIPTOR_VERSION,
SubagentError,
assertSubagentMaxDepth,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
@@ -27,6 +28,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false,
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
label: 'do a thing',
prompt: [{ type: 'text', text: 'do a thing' }],
parent: fakeParent(),
signal: new AbortController().signal,
@@ -37,7 +39,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
class StubProvider implements SubagentProvider {
readonly inheritsParentContext = false
startCount = 0
lastRequest: SubagentStartRequest | undefined
lastRequest: ResolvedSubagentStartRequest | undefined
constructor(
readonly name: string,
@@ -48,7 +50,7 @@ class StubProvider implements SubagentProvider {
},
) {}
async start(request: SubagentStartRequest): Promise<SubagentRun> {
async start(request: ResolvedSubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
this.lastRequest = request
return {
@@ -104,16 +106,23 @@ describe('SubagentService', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('borrows ordinary start requests and exposes no provider continuation operations', async () => {
it('resolves the one-shot descriptor and exposes no provider continuation operations', async () => {
const { subagents } = await service()
const provider = new StubProvider('one-shot')
subagents.registerProvider(provider)
const request = baseRequest()
await subagents.start('one-shot', request)
// One-shot start borrows the caller's exact request; the seam has no
// provider-facing resume or steer surface to dispatch through.
expect(provider.lastRequest).toBe(request)
expect(provider.lastRequest).toEqual({
...request,
descriptor: {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'one-shot',
label: 'do a thing',
},
})
expect(provider.lastRequest).not.toBe(request)
expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
expect('resume' in subagents).toBe(false)
expect('resume' in provider).toBe(false)
@@ -299,15 +308,17 @@ describe('subagent descriptors', () => {
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
expect(foldSubagentDescriptor([])).toBeUndefined()
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'child work' })
expect(minimal).toEqual({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'child work',
})
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
const complete = {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable' as const,
provider: 'spawn',
label: 'complete child',
agentProvider: 'deepseek',
@@ -316,6 +327,7 @@ describe('subagent descriptors', () => {
toolFilter: { allow: ['read'], deny: ['bash'] },
}
expect(snapshotSubagentDescriptor({
mode: 'continuable',
provider: complete.provider,
label: complete.label,
agentProvider: complete.agentProvider,
@@ -325,15 +337,28 @@ describe('subagent descriptors', () => {
})).toEqual(complete)
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { allow: ['read'] },
}),
])).toMatchObject({ toolFilter: { allow: ['read'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { deny: ['bash'] } }),
event({
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { deny: ['bash'] },
}),
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
expect(foldSubagentDescriptor([
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
])).toBeUndefined()
expect(() => snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'spawn',
label: 'bad',
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
@@ -346,19 +371,94 @@ describe('subagent descriptors', () => {
['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'],
['missing label', { version: 1, provider: 'spawn' }, 'label must be a string'],
['invalid label', { version: 1, provider: 'spawn', label: 7 }, 'label must be a string'],
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
['invalid agent provider', { version: 1, provider: 'spawn', label: 'l', agentProvider: 7 }, 'agentProvider must be a string'],
['invalid agent model', { version: 1, provider: 'spawn', label: 'l', agentModel: [] }, 'agentModel must be a string'],
['invalid persona', { version: 1, provider: 'spawn', label: 'l', persona: {} }, 'persona must be a string'],
['non-object tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: [] }, 'toolFilter must be an object'],
['unknown tool-filter field', { version: 1, provider: 'spawn', label: 'l', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
['empty tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
['non-array allow list', { version: 1, provider: 'spawn', label: 'l', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
['non-string deny item', { version: 1, provider: 'spawn', label: 'l', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
['missing mode', { version: SUBAGENT_DESCRIPTOR_VERSION }, 'mode must be "one-shot" or "continuable"'],
['invalid mode', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'later' }, 'mode must be "one-shot" or "continuable"'],
['unknown one-shot field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'one-shot',
provider: 'spawn',
label: 'l',
persona: 'reviewer',
}, 'payload has unknown field "persona"'],
['unknown payload field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
extra: true,
}, 'payload has unknown field "extra"'],
['missing provider', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable' }, 'provider must be a string'],
['missing label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
}, 'label must be a string'],
['invalid label', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 7,
}, 'label must be a string'],
['invalid provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 7,
}, 'provider must be a string'],
['invalid agent provider', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentProvider: 7,
}, 'agentProvider must be a string'],
['invalid agent model', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
agentModel: [],
}, 'agentModel must be a string'],
['invalid persona', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
persona: {},
}, 'persona must be a string'],
['non-object tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: [],
}, 'toolFilter must be an object'],
['unknown tool-filter field', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { except: ['bash'] },
}, 'toolFilter has unknown field "except"'],
['empty tool filter', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: {},
}, 'toolFilter must declare allow and/or deny'],
['non-array allow list', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
toolFilter: { allow: 'read' },
}, 'toolFilter.allow must be an array of strings'],
['non-string deny item', {
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'spawn',
label: 'l',
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)
})