feat(subagent): let ancestors interrupt descendants

interrupt_agent(agent_id) passes the calling agent as the ancestor
authority for ctx.subagents.interrupt(); the service verifies live
registry identity and recorded lineage, so a direct child or deeper
descendant stops with the same generic parameter while send_message keeps
its exact-direct-parent authority.

Discovery: list_agents gains an optional scope. descendants walks the new
SubagentService.listDescendants() — one lineage trace flattened in stable
pre-order across ordinary and one-shot intermediates, each entry carrying
its verified parentId and depth — and every status now comes from the
live Agent registry (running/idle/complete).

Refs #1535
This commit is contained in:
Hypatia May
2026-08-06 13:46:55 +08:00
committed by Tianyi Cui
parent 4b29f9ca7a
commit d769d3cbb7
41 changed files with 1325 additions and 162 deletions
+2 -2
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: eb1baa39231a2c058b3ada1a23b7b87e2bb2e384
README.zh.md: 8402df3e4ff2559898dfd4dd512ab1601c9ec61d
README.md: cd25bace16c91e8b44331dc8e5eab0987607e83a
README.zh.md: 05b1e4dd4c74b6d62df8b0a310534fc9476f3ade
+3 -2
View File
@@ -18,11 +18,12 @@ The [subagent family overview](../README.md) maps implementations and model-faci
| `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 published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. 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. |
| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. |
| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. An absent target is an accepted no-op; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. |
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
| `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 session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. |
| `listDescendants(rootSessionId, signal?)` | Flatten the root's complete session tree in stable pre-order from the same live-preferred corpus, adding each subagent entry's durable `parentId` and root-relative `depth`. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered. Identity, diagnostics, dependencies, and cancellation follow `listChildren()`. |
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `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 returned run's remaining turn work without hiding its id. 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.
@@ -85,7 +86,7 @@ When `ctx.sessionProjections` is available, the service registers two projection
## 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, and persists its model-supplied `description` as the optional display label. 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 or result promise — a caller sends later work with the `send_message` follow-up tool, while `interrupt()` stops only the current turn without disposing the child. 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()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. 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, and persists its model-supplied `description` as the optional display label. 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 and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, 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()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry (`running`/`idle`/`complete`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. 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 await a best-effort final session flush without treating listener participation as 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.
+4 -3
View File
@@ -18,11 +18,12 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 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`;冷恢复还要求会话持久化。 |
| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入同步、生效异步:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 与已发布的后代均保持不变;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方`UNAUTHORIZED` 拒绝;过期的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 |
| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入同步完成、生效异步进行:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 与已发布的后代均保持不变;已被领取进入中断轮次的工作不会重新入队。目标不存在时接受 no-op错误的 parent 地址以及过期、指向自身或非祖先调用方以 `UNAUTHORIZED` 拒绝。 |
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot``continuable` 模式、`running``inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化(持久化缺席时仅枚举在线 child),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 |
| `listDescendants(rootSessionId, signal?)` | 从同一份实时优先语料按稳定 pre-order 展平根的完整会话树,并为每个 subagent 条目附加持久 `parentId` 与相对根的 `depth`。普通会话与一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现。身份、diagnostic、依赖与取消契约均沿用 `listChildren()`。 |
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。
@@ -77,7 +78,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
提供方新增和移除还会发出 `subagent/provider-added``subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代——因为停止一个轮次是幂等的,且不投递任何内容。
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代因为停止一个轮次是幂等的,且不投递任何内容。
`ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start``turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since``active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。
@@ -85,7 +86,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次不 dispose 子 agent持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnosticinspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running``complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 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,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次不 dispose 子 agent,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnosticinspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态(`running``idle``complete`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 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 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
+25 -6
View File
@@ -19,9 +19,9 @@
* resident. Continuable children never become a {@link SubagentRun}: the
* continuation manager holds their `AgentHandle` directly and orders every turn
* through the child's own inbox, so providers contribute only the detached
* creation spec and see no handle, turn, or teardown. Direct-child discovery
* reads the live session store and optional session persistence directly and
* does not require that continuation runtime.
* creation spec and see no handle, turn, or teardown. Child and descendant
* discovery read the live session store and optional session persistence
* directly and do not require that continuation runtime.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -63,8 +63,8 @@ import type {
} from './continuation.ts'
import SubagentActivationSetupRegistry from './activation-setup-registry.ts'
import type { ContinuableSetupContribution } from './activation-setup-registry.ts'
import { listChildren as listSubagentChildren } from './list-children.ts'
import type { SubagentListEntry } from './list-children.ts'
import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts'
import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
@@ -118,7 +118,7 @@ export type {
SubagentReportOptions,
} from './continuation.ts'
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
export type { SubagentListEntry } from './list-children.ts'
export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
@@ -336,6 +336,25 @@ export class SubagentService extends Service {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
/**
* Enumerate the root's complete session-backed subagent tree in stable
* pre-order from one live-preferred corpus, without loading or resuming an
* Agent. Ordinary sessions and one-shot children remain traversal nodes so
* continuable descendants below them are discovered; each returned entry
* adds its durable `parentId` and root-relative `depth`. Identity resolution,
* diagnostics, optional persistence, and cancellation follow the same
* projection-backed contract as {@link listChildren}.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation forwarded to persistence reads
* and observed around every read await.
* @returns children and per-candidate diagnostics with tree position, in
* stable pre-order.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]> {
return listSubagentDescendants(this.ctx, rootSessionId, signal)
}
/**
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that
+139 -20
View File
@@ -1,18 +1,16 @@
/**
* Read-only enumeration of one parent's durable subagent children straight
* from the live session store and optional session persistence — no query
* seam. Candidates are the live-preferred merge of both listings filtered to
* durable `origin: 'subagent'` under the parent; each child's mode/label is
* the registered `subagent` projection unit's value, resolved down a
* three-rung ladder: the registry's watermark cache for a live child, a
* durable projection-cache row when it serves an own-suffix identity (the
* Read-only enumeration of durable subagent children and descendant trees
* straight from the live session store and optional session persistence — no
* query seam. Candidates come from one live-preferred corpus; each child's
* mode/label is the registered `subagent` projection unit's value, resolved
* down a three-rung ladder: the registry's watermark cache for a live child,
* a durable projection-cache row when it serves an own-suffix identity (the
* seq gate), and one persistence inspection folded through the registry
* otherwise, validated against the enumerated lifecycle. The projection
* fold is the single
* classification authority — this module parses no descriptor itself. Absent
* persistence, enumeration is live-only: a cold child is unreachable for
* resume anyway, so its absence is capability absence, not an error. The
* module owns no catalog state and does not consult Activation,
* otherwise, validated against the enumerated lifecycle. The projection fold
* is the single classification authority — this module parses no descriptor
* itself. Absent persistence, enumeration is live-only: a cold child is
* unreachable for resume anyway, so its absence is capability absence, not an
* error. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state.
*
* @module @deepseek-ai/dsh-subagent
@@ -88,6 +86,34 @@ export type SubagentListEntry =
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/**
* One entry of a descendant listing: the interpreted subagent facts plus its
* position in the complete session tree. `parentId` is the durable direct
* parent from the enumerated header, and `depth` counts edges from the root.
*/
export type SubagentDescendantListEntry = SubagentListEntry & {
/** Durable direct parent of this candidate in the enumerated tree. */
readonly parentId: SessionId
/** Edge distance from the requested root; direct children are `1`. */
readonly depth: number
}
type CorpusRecord = { readonly header: SessionHeader; readonly live: Session | undefined }
interface ListingRuntime {
readonly projections: SessionProjectionRegistry
readonly persistence: SessionPersistence | undefined
readonly cache: SessionProjectionCache | undefined
readonly corpus: ReadonlyMap<SessionId, CorpusRecord>
readonly subagentParents: ReadonlySet<SessionId>
}
interface PositionedCandidate {
readonly record: CorpusRecord
readonly parentId: SessionId
readonly depth: number
}
/**
* Enumerate one parent's origin-classified direct children from the
* live-preferred merge of `ctx.sessions` and optional session persistence,
@@ -110,6 +136,55 @@ export async function listChildren(
parentSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentListEntry[]> {
const listing = await prepareListing(ctx, signal)
const candidates = [...listing.corpus.values()]
.filter(record => record.header.parentSession === parentSessionId
&& record.header.origin === 'subagent')
.sort(compareCorpusRecords)
const rows = await resolveCandidateRows(candidates, listing, signal)
return rows.filter((row): row is SubagentListEntry => row !== undefined)
}
/**
* Enumerate every session-backed subagent below one root in stable pre-order.
* Ordinary sessions and one-shot children remain traversal nodes, so a
* continuable child below either is still discovered. Classification uses the
* same projection-backed runtime as {@link listChildren}; no Agent is loaded or
* resumed.
* @see SubagentService.listDescendants for the public cancellation and failure contract.
* @param ctx - context carrying the session store, projection registry, and optional persistence/cache.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation observed around every persistence read.
* @returns interpreted subagents with durable direct-parent and root-relative depth.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
*/
export async function listDescendants(
ctx: Context,
rootSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentDescendantListEntry[]> {
const listing = await prepareListing(ctx, signal)
const positioned = descendantCandidates(listing.corpus, rootSessionId)
const rows = await resolveCandidateRows(
positioned.map(candidate => candidate.record),
listing,
signal,
)
const entries: SubagentDescendantListEntry[] = []
positioned.forEach((position, index) => {
const row = rows[index]
if (row !== undefined) {
entries.push({ ...row, parentId: position.parentId, depth: position.depth })
}
})
return entries
}
/** Resolve listing services once and build one live-preferred session corpus. */
async function prepareListing(
ctx: Context,
signal: AbortSignal | undefined,
): Promise<ListingRuntime> {
const projections = ctx.get('sessionProjections')
// Checked before any read, even with zero candidates: mode/label are the
// row's strong contract, so a missing fold capability is a deterministic
@@ -150,7 +225,7 @@ export async function listChildren(
}
// Live-preferred merge without header reconciliation: a live record wins
// its id wholesale, exactly as a live-preferred corpus would serve it.
const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>()
const corpus = new Map<SessionId, CorpusRecord>()
for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
for (const session of sessions.list()) {
corpus.set(session.header.id, { header: session.header, live: session })
@@ -161,12 +236,16 @@ export async function listChildren(
subagentParents.add(record.header.parentSession)
}
}
const candidates = [...corpus.values()]
.filter(record => record.header.parentSession === parentSessionId
&& record.header.origin === 'subagent')
.sort((a, b) => a.header.createdAt - b.header.createdAt
|| a.header.id.localeCompare(b.header.id))
return { projections, persistence, cache, corpus, subagentParents }
}
/** Resolve projection-backed rows for aligned candidates with bounded cold reads. */
async function resolveCandidateRows(
candidates: readonly CorpusRecord[],
listing: ListingRuntime,
signal: AbortSignal | undefined,
): Promise<(SubagentListEntry | undefined)[]> {
const { projections, persistence, cache, subagentParents } = listing
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
const coldReads: { index: number; header: SessionHeader }[] = []
candidates.forEach((candidate, index) => {
@@ -212,7 +291,47 @@ export async function listChildren(
))
}
assertListingNotCancelled(signal)
return rows.filter((row): row is SubagentListEntry => row !== undefined)
return rows
}
/** Build origin-classified candidates from the complete tree without recursion. */
function descendantCandidates(
corpus: ReadonlyMap<SessionId, CorpusRecord>,
rootSessionId: SessionId,
): PositionedCandidate[] {
const children = new Map<SessionId, CorpusRecord[]>()
for (const record of corpus.values()) {
const parentId = record.header.parentSession
if (parentId === undefined) continue
const siblings = children.get(parentId)
if (siblings === undefined) children.set(parentId, [record])
else siblings.push(record)
}
for (const siblings of children.values()) siblings.sort(compareCorpusRecords)
const positioned: PositionedCandidate[] = []
const stack: PositionedCandidate[] = (children.get(rootSessionId) ?? [])
.map(record => ({ record, parentId: rootSessionId, depth: 1 }))
.reverse()
const visited = new Set<SessionId>([rootSessionId])
while (stack.length > 0) {
const position = stack.pop()
if (position === undefined) break
const id = position.record.header.id
if (visited.has(id)) continue
visited.add(id)
if (position.record.header.origin === 'subagent') positioned.push(position)
const descendants = children.get(id) ?? []
for (const record of [...descendants].reverse()) {
stack.push({ record, parentId: id, depth: position.depth + 1 })
}
}
return positioned
}
/** Compare siblings by durable creation time, then id. */
function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number {
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
}
/**
@@ -972,3 +972,197 @@ describe('SubagentService.listChildren', () => {
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
})
})
describe('SubagentService.listDescendants', () => {
it('flattens the complete tree in stable pre-order with verified parent and depth', async () => {
const { ctx, parent } = await setup([])
const childA = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('branch a')))
const grandchild = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa2', {
parentSession: childA,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under a')))
const childB = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa3', {
parentSession: parent.id,
createdAt: 3,
origin: 'subagent',
}, childEvents(descriptorPayload('branch b')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: childA, label: 'branch a', mode: 'continuable',
activity: 'inactive', hasChildren: true, parentId: parent.id, depth: 1,
},
{
kind: 'child', id: grandchild, label: 'under a', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: childA, depth: 2,
},
{
kind: 'child', id: childB, label: 'branch b', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1,
},
])
})
it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => {
const { ctx, parent } = await setup([])
const depth = 10_000
let parentId = parent.id
for (let level = 1; level < depth; level += 1) {
const session = ctx.sessions.create(SessionId(`deep-ordinary-${level}`), {
meta: { createdAt: level, parentSession: parentId },
})
parentId = session.id
}
const leafId = SessionId('deep-subagent-leaf')
const leaf = ctx.sessions.create(leafId, {
meta: { createdAt: depth, parentSession: parentId, origin: 'subagent' },
})
leaf.append('turn/start', { turn: 1 })
leaf.append('subagent/descriptor', descriptorPayload('deep leaf'))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: leafId, label: 'deep leaf', mode: 'continuable',
activity: 'running', hasChildren: false, parentId, depth,
}])
})
it('discovers continuable descendants below ordinary and one-shot intermediates', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// An ordinary fork has no descriptor: omitted itself, subtree still walked.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const underFork = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb1', {
parentSession: fork.header.id,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under the fork')))
// A real one-shot child, then a continuable authored below it.
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot intermediate',
prompt: [{ type: 'text', text: 'one-shot task' }],
parent,
signal: testSignal,
})
await oneShot.result
await ctx.sessions.flush(oneShot.localAgent!.session)
const oneShotId = oneShot.id
await oneShot.dispose()
const underOneShot = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb2', {
parentSession: oneShotId,
createdAt: 9_999_999_999_999,
origin: 'subagent',
}, childEvents(descriptorPayload('under the one-shot')))
const entries = await ctx.subagents.listDescendants(parent.id)
// The fork is absent (descriptor-less); the one-shot is present with its
// mode so a caller can see the lineage it walked through.
expect(entries.map(entry => entry.id)).not.toContain(fork.header.id)
expect(entries).toContainEqual({
kind: 'child', id: underFork, label: 'under the fork', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: fork.header.id, depth: 2,
})
expect(entries).toContainEqual(expect.objectContaining({
kind: 'child', id: oneShotId, mode: 'one-shot', parentId: parent.id, depth: 1,
}))
expect(entries).toContainEqual({
kind: 'child', id: underOneShot, label: 'under the one-shot', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: oneShotId, depth: 2,
})
// Pre-order: every child appears after its own parent entry.
const position = new Map(entries.map((entry, index) => [entry.id, index]))
expect(position.get(underOneShot)!).toBeGreaterThan(position.get(oneShotId)!)
})
it('diagnoses a settled descriptor-less node while walking its subtree', async () => {
const { ctx, parent } = await setup([])
// A settled origin-marked candidate without an identity is corrupt under
// the projection contract, but its subtree remains independently visible.
const bare = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee2', {
parentSession: bare,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the bare node')))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: bare, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the bare node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: bare, depth: 2,
},
])
})
it('keeps traversing below a corrupt intermediate and positions its diagnostic', async () => {
const { ctx, parent } = await setup([])
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('unsupported descriptor', 999)))
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc2', {
parentSession: corrupt,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the corrupt node')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{ kind: 'diagnostic', id: corrupt, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the corrupt node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: corrupt, depth: 2,
},
])
})
it('verifies a cold candidate still belongs to its enumerated lifecycle', async () => {
const { ctx, parent } = await setup([])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ddd1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('lineage checked')))
const realInspect = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
const inspected = await realInspect(sessionId, signal)
// The exact read reports a different durable parent than enumeration did.
return { ...inspected, meta: { ...inspected.meta, parentSession: SessionId('someone-else') } }
}
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 },
])
})
it('a pre-aborted signal stops the descendant scan before persistence reads', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never read')
const list = vi.spyOn(ctx.sessionPersistence, 'list')
const controller = new AbortController()
controller.abort()
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
expect(list).not.toHaveBeenCalled()
})
it('fails loud when the projection registry is not mounted', async () => {
const { ctx, parent } = await setup([], { sessionProjections: false })
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
)
})
})