mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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 .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: 8337a926238bf7fc4395896fcd4ca180c9c1ac1c
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: bcf2895a9a69c8cff949788c78158bfccd198c5c
|
||||
|
||||
@@ -23,7 +23,7 @@ Parent-to-child enumeration is a service capability with consumer-specific proje
|
||||
- report corpus activity separately as `running` or `inactive`, without implying completion or resumability;
|
||||
- return every resulting child in stable `createdAt` ascending, child-id ascending order.
|
||||
|
||||
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and maps `inactive` to its existing `complete` presentation; a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
|
||||
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and refines status through the live Agent registry (`running`/`idle`/`complete`); a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
|
||||
|
||||
### Enumeration decision
|
||||
|
||||
@@ -52,7 +52,7 @@ If measured scale later requires an index, that index is derived state: session
|
||||
|
||||
A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. `mode` is durable creation policy; `activity` is a process-local corpus snapshot. Activity is neither `AgentStatus`, the manager's internal Activation state, nor a durable outcome, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature.
|
||||
|
||||
The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, maps a continuable child's `running` activity to `running` and `inactive` activity to `complete`, then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in the surviving trace order. An empty projection renders `(no subagents)`.
|
||||
The model-facing `list_agents` tool takes one optional `scope: 'children' | 'descendants'` argument, derives the root id from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, derives status from the live Agent registry — `running` for an active driver, `idle` for a resident Agent between turns, and `complete` when no live Agent remains — then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in stable catalog order. The `descendants` scope reads `SubagentService.listDescendants(rootSessionId)`, which flattens the complete tree from one live-preferred corpus in stable pre-order, traverses ordinary and one-shot intermediates so deeper continuable agents are discovered, revalidates each cold candidate against its enumerated lifecycle, and adds `parentId`/`depth` to every entry. The tool inserts ` parent=<id> depth=<n>` before the label; `parent` is the durable direct-parent session id and may name an omitted ordinary session. For the current caller, only depth-1 child rows are `send_message` candidates, while deeper child rows may be selected for `interrupt_agent` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Discovery is a hint only — follow-up authority stays exact-direct-parent, and interrupt authority stays with the service's live-lineage check. An empty projection renders `(no subagents)`.
|
||||
|
||||
Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, a read result whose immutable header differs from the traced candidate or no longer names the requested direct parent, a target that is no longer the located descriptor event, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; the first version neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a non-subagent exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic.
|
||||
|
||||
@@ -93,8 +93,8 @@ The first version has no child deletion operation. If later product behavior del
|
||||
## Testing
|
||||
|
||||
- `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; and typed stable error codes. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, the narrowed load requirement without `sessionQuery`, and HMR disposal.
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (one optional `scope` enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the `agents` load requirement without `sessionQuery`, and HMR disposal.
|
||||
- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [complete] — <label>`.
|
||||
- The keyless snapshot scenario `subagent-diagnostic` (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic.
|
||||
- The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result.
|
||||
|
||||
@@ -23,7 +23,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
|
||||
- 将语料活动状态单独报告为 `running` 或 `inactive`,但不暗示已完成或可恢复;
|
||||
- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
|
||||
|
||||
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并将 `inactive` 映射为其现有的 `complete` 表示;UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
|
||||
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`);UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
|
||||
|
||||
### 枚举决策
|
||||
|
||||
@@ -52,7 +52,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务
|
||||
|
||||
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。
|
||||
|
||||
面向模型的 `list_agents` 工具不接受参数,从当前正在执行的 Agent 推导 `parentSessionId`,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它保留 diagnostic,丢弃 `one-shot` child 条目,将可继续 child 的 `running` 活动状态映射为 `running`、`inactive` 活动状态映射为 `complete`,然后按剩余的追踪顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。空投影渲染为 `(no subagents)`。
|
||||
面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `complete`——然后按稳定目录顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。`descendants` scope 读取 `SubagentService.listDescendants(rootSessionId)`:它从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent,依据枚举生命周期重新校验每个冷候选,并为每个条目附加 `parentId`/`depth`。工具会在 label 之前插入 ` parent=<id> depth=<n>`;`parent` 是持久化直接 parent 会话 id,可能指向被省略的普通会话。对于当前调用方,只有 depth-1 child 条目可作为 `send_message` 候选,更深的 child 条目则可供 `interrupt_agent` 选择([中断契约](2026-08-06-continuable-subagent-interrupt.md))。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent,中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 `(no subagents)`。
|
||||
|
||||
diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。
|
||||
|
||||
@@ -93,8 +93,8 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
|
||||
## 测试
|
||||
|
||||
- `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;以及带类型的稳定错误码。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、收窄后的加载要求(不再注入 `sessionQuery`),以及 HMR dispose。
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents` 但不再注入 `sessionQuery` 的加载契约,以及 HMR dispose。
|
||||
- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。
|
||||
- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
|
||||
- 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
|
||||
2026-08-06-continuable-subagent-interrupt.md: 1792d4c6d25858c861be0ff920206d0d4e684f41
|
||||
2026-08-06-continuable-subagent-interrupt.zh.md: ff9887481d1ded2743c7c8f9f162413fb6581d70
|
||||
2026-08-06-continuable-subagent-interrupt.md: 0f663ac6a58b9c3d658aa572ca2fea18dbef6449
|
||||
2026-08-06-continuable-subagent-interrupt.zh.md: b8a5b557b73b2845e3c0b1dabdec0163f56eca48
|
||||
|
||||
@@ -39,8 +39,10 @@ A human or ancestor can stop a runaway continuable turn without losing the child
|
||||
|
||||
The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability.
|
||||
|
||||
The Web surface reuses the existing primary Send/Stop toggle rather than adding a second action: the client `Session.cancel()` routes a continuable address through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep `session.cancel`), and a running parent-offline continuable child keeps the default composer with disabled input so that same primary Stop remains reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract). The model-facing `interrupt_agent` tool builds on this primitive in the stacked follow-up PR for issue #1535.
|
||||
The Web surface reuses the existing primary Send/Stop toggle rather than adding a second action: the client `Session.cancel()` routes a continuable address through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep `session.cancel`), and a running parent-offline continuable child keeps the default composer with disabled input so that same primary Stop remains reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
|
||||
|
||||
The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the new `SubagentService.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](2026-07-22-durable-subagent-catalog-and-list-agents.md) owns the listing contract); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
|
||||
|
||||
## Testing
|
||||
|
||||
Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar Send/Stop toggle with the parent-offline locked-input state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold a real child turn open with a replay hang entry and prove the interrupt transport, the aborted `turn/end`, the parked follow-up, and the FIFO resume end to end.
|
||||
Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar Send/Stop toggle with the parent-offline locked-input state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold a real child turn open with a replay hang entry and prove the interrupt transport, the aborted `turn/end`, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshots pin the new tool schemas in every recorded request header.
|
||||
|
||||
@@ -39,8 +39,10 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回
|
||||
|
||||
仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。
|
||||
|
||||
Web 侧复用现有的 primary Send/Stop 切换而不新增第二个操作:客户端 `Session.cancel()` 将 continuable 地址路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍走 `session.cancel`);parent 离线但仍在运行的 continuable child 保留默认 composer 并禁用其输入区,让同一个 primary Stop 保持可达,停止后恢复只读替代(周边目录与 composer 契约由 [Web subagent 对话](2026-07-27-web-subagent-conversations.md)拥有)。面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。
|
||||
Web 侧复用现有的 primary Send/Stop 切换而不新增第二个操作:客户端 `Session.cancel()` 将 continuable 地址路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍走 `session.cancel`);parent 离线但仍在运行的 continuable child 保留默认 composer 并禁用其输入区,让同一个 primary Stop 保持可达,停止后恢复只读替代(周边目录与 composer 契约由 [Web subagent 对话](2026-07-27-web-subagent-conversations.md)拥有)。
|
||||
|
||||
`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentService.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId`/`depth`(列表契约由[持久化目录 note](2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的 Send/Stop 切换及 parent 离线时锁定输入的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)用 replay hang 条目保持真实 child 轮次打开,端到端证明中断传输、中止的 `turn/end`、follow-up 暂停以及 FIFO 恢复。
|
||||
`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的 Send/Stop 切换及 parent 离线时锁定输入的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)用 replay hang 条目保持真实 child 轮次打开,端到端证明中断传输、中止的 `turn/end`、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照把新工具 schema 固定在每个已录制请求 header 中。
|
||||
|
||||
@@ -30,6 +30,7 @@ const EXPECTED_TOOLS = [
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'interrupt_agent',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
|
||||
@@ -2153,6 +2153,23 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
|
||||
*/
|
||||
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
|
||||
|
||||
/**
|
||||
* Enumerate the root's complete session-backed subagent tree in stable
|
||||
* pre-order from one lineage trace, without loading or resuming an Agent.
|
||||
* Ordinary sessions and one-shot children are traversed so continuable
|
||||
* descendants below them are discovered; each returned entry adds its
|
||||
* verified `parentId` and root-relative `depth`. Cancellation follows the
|
||||
* same contract as {@link listChildren}.
|
||||
* @param rootSessionId - session whose complete descendant tree is listed.
|
||||
* @param signal - caller-owned cancellation forwarded where supported and
|
||||
* observed around every query await.
|
||||
* @returns children and per-candidate diagnostics with tree position, in
|
||||
* stable pre-order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or the
|
||||
* caller cancels the scan.
|
||||
*/
|
||||
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -2188,7 +2205,7 @@ list(): string[]
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentInterruptAuthority](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentDescendantListEntry](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentInterruptAuthority](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md
|
||||
subagent.md: 4de77c6232d73d4b4e9dd87e9afc7a077e61f1a0
|
||||
subagent.zh.md: 3310bb4bf8cad1314ec26eef9042955959aba5fe
|
||||
subagent.md: b26a12d1d50305d86d7ada29cac83474009d81ce
|
||||
subagent.zh.md: 6c4c64ff22050b73699a97093acd0032668fde3d
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](subagent.zh.md)
|
||||
|
||||
The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor.
|
||||
|
||||
Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
|
||||
|
||||
@@ -263,9 +263,26 @@ The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subag
|
||||
|
||||
A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime.
|
||||
|
||||
## Durable enumeration: `listChildren()` and `SubagentListEntry`
|
||||
## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry's `running`/`idle`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md).
|
||||
|
||||
`SubagentService.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query seam, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished, with `unsupported` kept in the type for consumers already routing on it but no longer produced; a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and maps activity to its existing `running`/`complete` vocabulary. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md).
|
||||
|
||||
## The terminal result: `SubagentResult`
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。
|
||||
|
||||
接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接从会话存储与可选的会话持久化负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为六个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接从会话存储与可选的会话持久化负责只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
|
||||
|
||||
@@ -263,9 +263,26 @@ interface ContinuableCreateSpec {
|
||||
|
||||
本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。
|
||||
|
||||
## 持久化枚举:`listChildren()` 与 `SubagentListEntry`
|
||||
## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为 `running`/`idle`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。
|
||||
|
||||
`SubagentService.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询 seam,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 契约负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分,`unsupported` 为已按其路由的消费方保留在类型中但不再产出;运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时只要求 `ctx.subagents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。
|
||||
|
||||
## 终态结果:`SubagentResult`
|
||||
|
||||
|
||||
@@ -94,8 +94,16 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. */
|
||||
list_agents: Record<string, JsonValue>;
|
||||
/** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */
|
||||
interrupt_agent: {
|
||||
/** The agent id of the running agent to interrupt. */
|
||||
agent_id: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent. */
|
||||
list_agents: {
|
||||
/** children (default) lists direct children only; descendants walks the complete tree below you. */
|
||||
scope?: "children" | "descendants";
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
@@ -304,15 +312,22 @@ interface ToolOutputMap {
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
interrupt_agent: {
|
||||
accepted: boolean;
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "complete";
|
||||
status: "running" | "idle" | "complete";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -173,11 +173,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -77,8 +77,16 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. */
|
||||
list_agents: Record<string, JsonValue>;
|
||||
/** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */
|
||||
interrupt_agent: {
|
||||
/** The agent id of the running agent to interrupt. */
|
||||
agent_id: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent. */
|
||||
list_agents: {
|
||||
/** children (default) lists direct children only; descendants walks the complete tree below you. */
|
||||
scope?: "children" | "descendants";
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
@@ -275,15 +283,22 @@ interface ToolOutputMap {
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
interrupt_agent: {
|
||||
accepted: boolean;
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "complete";
|
||||
status: "running" | "idle" | "complete";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status is a snapshot of the stored record: running means the subagent session is currently live in this process, complete means it exists only in storage and a `send_message` starts a new turn on the same conversation. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a `send_message` starts a new turn on the same conversation either way. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in creation order, annotating each entry with its parent agent id and depth so a deeper agent can be selected for send_message or interrupt_agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -948,6 +948,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
|
||||
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>',
|
||||
jsDoc: '/**\n * Enumerate the root\'s complete session-backed subagent tree in stable\n * pre-order from one lineage trace, without loading or resuming an Agent.\n * Ordinary sessions and one-shot children are traversed so continuable\n * descendants below them are discovered; each returned entry adds its\n * verified `parentId` and root-relative `depth`. Cancellation follows the\n * same contract as {@link listChildren}.\n * @param rootSessionId - session whose complete descendant tree is listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n * observed around every query await.\n * @returns children and per-candidate diagnostics with tree position, in\n * stable pre-order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerProvider(provider: SubagentProvider): () => void',
|
||||
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
|
||||
@@ -2783,6 +2787,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SubagentCapabilities',
|
||||
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentDescendantListEntry',
|
||||
declaration: 'export type SubagentDescendantListEntry = SubagentListEntry & {\n readonly parentId: SessionId;\n readonly depth: number;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SubagentDescriptorData',
|
||||
declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;',
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
@@ -49,6 +49,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
expect(bash?.sources.bash).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control')
|
||||
expect(control?.sources).toEqual({
|
||||
interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` diagnostic,inspect 失败是瞬时的 `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` diagnostic,inspect 失败是瞬时的 `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 历史虚构目录条目。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/tool-subagent-control/README.md
|
||||
README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
|
||||
README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1
|
||||
README.md: 4d9991b720bbbff8862e693b6aaed332f414af0e
|
||||
README.zh.md: dc1c81fc3313a8dfa277f33ae745702ed5b9d34b
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
The optional, globally named `send_message`, `interrupt_agent`, and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and `interrupt_agent` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents` and declares `subagents` plus `agents` as load-time dependencies. Its catalog reads additionally require the session store and projection registry at call time, but no query service. A deployment can keep the root tools while omitting the list tool. No tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
|
||||
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
`list_agents` takes no arguments, derives the parent id from the calling agent, and projects `ctx.subagents.listChildren()` to continuable children without a cursor. The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
|
||||
`interrupt_agent(agent_id)` passes `exec.agent` as the exact live ancestor authority for `ctx.subagents.interrupt()`: the target may be a direct child or a deeper descendant, and the service — never this tool — verifies the caller against the target Activation's recorded lineage. Only the target's current turn stops (`keepInbox`): queued messages stay parked until a later `send_message`, published descendants keep running, and the child stays available for follow-ups. The call returns as soon as the stop request is accepted, without waiting for target quiescence; an absent or already-settled target is an accepted no-op, while self, sibling, stale, and non-ancestor callers become errored results.
|
||||
|
||||
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), `complete` (storage only). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,7 +16,7 @@ The tool performs no lifecycle routing — residency and cold resume belong to t
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered.
|
||||
The generated [schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `send_message` takes `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered; `interrupt_agent` takes `agent_id`, describing that only the current turn stops, queued messages park, descendants keep running, and acceptance precedes the actual stop; `list_agents` takes the optional `scope` enum.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -24,6 +26,20 @@ Fixed schema cost per parent request.
|
||||
|
||||
Prefix-stable; the schema does not change at runtime.
|
||||
|
||||
### Interrupt result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`interrupt requested for agent <agent_id>` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; an absent or settled target still renders the acceptance line.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One short acknowledgement per call; the interrupted turn's abort is visible only in the child's own transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; each result follows the reusable request prefix.
|
||||
|
||||
### Delivery result
|
||||
|
||||
#### What the model sees
|
||||
@@ -42,11 +58,11 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One line per continuable child in the trace's stable order: `<id> [<status>] — <label>` (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `complete` = storage only; a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Grows linearly with the parent's direct continuable children; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
|
||||
Grows linearly with the listed continuable children — the whole tree under the `descendants` scope; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -56,5 +72,5 @@ Append-only; each result follows the reusable request prefix.
|
||||
|
||||
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work lands in the durable child Session and is never collected through this tool. A child granted `report` may send selected content back separately, but that message is not this call's result.
|
||||
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
|
||||
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
可选的全局具名 `send_message`、`interrupt_agent` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message` 与 `interrupt_agent`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,并将 `subagents` 与 `agents` 声明为加载时依赖。其目录读取在调用时还要求会话存储与投影注册表,但不要求任何查询服务。部署可保留根插件工具并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
|
||||
本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
`list_agents` 不接受参数,会从调用它的 agent 推导 parent id,并且不使用 cursor,将 `ctx.subagents.listChildren()` 的结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
|
||||
`interrupt_agent(agent_id)` 将 `exec.agent` 作为 `ctx.subagents.interrupt()` 的确切在线 ancestor 授权传入:目标可以是直接 child 或更深的后代,由服务——而不是本工具——依据目标 Activation 记录的 lineage 校验调用方。只有目标的当前轮次会停止(`keepInbox`):已排队的消息保持暂停直到之后的 `send_message`,已发布的后代继续运行,child 也仍可接受后续消息。调用在停止请求被接受后立即返回,不等待目标静止;目标不存在或已结算是被接受的 no-op,而 self、sibling、过期与非 ancestor 调用方会成为出错结果。
|
||||
|
||||
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id,并且不使用 cursor,将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()`;`descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child,并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id,可能指向输出中省略的普通会话。对于调用本工具的 agent,只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`(driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent)、`complete`(仅存于存储)。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,7 +16,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达。
|
||||
已生成的 [schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):`send_message` 包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达;`interrupt_agent` 包含 `agent_id`,说明只有当前轮次会停止、已排队消息保持暂停、后代继续运行,以及接受先于实际停止;`list_agents` 包含可选的 `scope` 枚举。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -24,6 +26,20 @@
|
||||
|
||||
前缀保持稳定;schema 不会在运行时改变。
|
||||
|
||||
### 中断结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
接受时返回 `interrupt requested for agent <agent_id>`。未授权的调用方——self、sibling、过期或非 ancestor——会成为指明拒绝原因的出错结果;目标不存在或已结算仍渲染接受行。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用产生一条简短确认消息;被中断轮次的中止只在 child 自己的 transcript 中可见。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;每个结果都位于可复用请求前缀之后。
|
||||
|
||||
### 投递结果
|
||||
|
||||
#### 模型看到的内容
|
||||
@@ -42,11 +58,11 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
按追踪结果的稳定顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示逻辑会话存活,`complete` 表示仅存在于持久化存储中,可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`complete` 表示仅存于存储;处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
随 parent 的直接可继续 child 数量线性增长;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
|
||||
随所列可继续 child 数量线性增长——`descendants` scope 下为整棵树;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -56,5 +72,5 @@
|
||||
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
|
||||
- **不对当前轮次进行 steering(中途引导)**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
|
||||
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* The globally named `send_message` tool: a thin model-facing adapter over
|
||||
* `ctx.subagents.followup()`. It performs no lifecycle routing of its own —
|
||||
* residency and cold resume belong to the subagent service — and it lives apart
|
||||
* from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple
|
||||
* delegation tools share one control tool.
|
||||
* The globally named `send_message` and `interrupt_agent` tools: thin
|
||||
* model-facing adapters over `ctx.subagents.followup()` and
|
||||
* `ctx.subagents.interrupt()`. They perform no lifecycle routing of their own —
|
||||
* residency, cold resume, and interrupt authorization belong to the subagent
|
||||
* service — and they live apart from the provider-bound
|
||||
* `@deepseek-ai/dsh-tool-subagent` instances so multiple delegation tools share
|
||||
* one control surface.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control
|
||||
*/
|
||||
|
||||
@@ -17,7 +19,7 @@ export const name = 'tool-subagent-control'
|
||||
export const inject = ['tools', 'subagents']
|
||||
|
||||
/**
|
||||
* Register the `send_message` tool.
|
||||
* Register the `send_message` and `interrupt_agent` tools.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
@@ -73,4 +75,46 @@ export function apply(ctx: Context): void {
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'interrupt_agent',
|
||||
description:
|
||||
'Request cancellation of a background agent\'s current turn by its agent id. The target may be your '
|
||||
+ 'direct child or a deeper agent created under you. Only the current turn stops: messages already '
|
||||
+ 'queued for the agent stay parked until a later send_message, agents it started keep running, and '
|
||||
+ 'the agent itself stays available for follow-ups. This call returns as soon as the stop request is '
|
||||
+ 'accepted, so the target may keep running briefly; interrupting an agent that already finished is '
|
||||
+ 'an accepted no-op.',
|
||||
parameters: {
|
||||
agent_id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The agent id of the running agent to interrupt.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accepted: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (args, _value) => [{
|
||||
type: 'text',
|
||||
text: `interrupt requested for agent ${args.agent_id}`,
|
||||
}],
|
||||
},
|
||||
execute(args, exec) {
|
||||
const caller = exec.agent
|
||||
if (!caller) {
|
||||
// Ancestor authority requires an exact live calling agent.
|
||||
throw new Error('interrupt_agent requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
// The service authorizes the exact live caller against the target's
|
||||
// recorded lineage; the tool adds no authority of its own.
|
||||
ctx.subagents.interrupt(SessionId(args.agent_id), { kind: 'ancestor', agent: caller })
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,46 +1,95 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It stays
|
||||
* separately loadable from the root `send_message` plugin so a deployment
|
||||
* can register `send_message` without exposing the list tool.
|
||||
* the continuable projection of `ctx.subagents.listChildren()` and, for the
|
||||
* `descendants` scope, `ctx.subagents.listDescendants()`. It stays separately
|
||||
* loadable from the root `send_message` plugin so a deployment can register
|
||||
* continuation delivery without exposing discovery.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentDescendantListEntry, SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents']
|
||||
export const inject = ['tools', 'subagents', 'agents']
|
||||
|
||||
type ListAgentsEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'complete'
|
||||
readonly status: 'running' | 'idle' | 'complete'
|
||||
readonly parent?: string
|
||||
readonly depth?: number
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
readonly id: string
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
readonly parent?: string
|
||||
readonly depth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Refine one candidate's status through the live Agent registry: `running`
|
||||
* for an active driver, `idle` for a resident Agent between turns (possibly
|
||||
* waiting on agents it started), and `complete` when no live Agent remains.
|
||||
*/
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
|
||||
const agent = agents.get(id)
|
||||
if (agent === undefined) return 'complete'
|
||||
return agent.status === 'running' ? 'running' : 'idle'
|
||||
}
|
||||
|
||||
/** Project one service row into the model-facing entry, or omit a one-shot child. */
|
||||
function project(
|
||||
agents: { get(id: SessionId): Agent | undefined },
|
||||
entry: SubagentListEntry,
|
||||
position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
|
||||
): ListAgentsEntry | undefined {
|
||||
const at = position === undefined ? {} : { parent: position.parentId as string, depth: position.depth }
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
|
||||
}
|
||||
// One-shot children cannot be continued by send_message, so the model
|
||||
// never selects them; discovery still traversed them for descendants.
|
||||
if (entry.mode !== 'continuable') return undefined
|
||||
return {
|
||||
kind: 'child',
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
status: statusOf(agents, entry.id),
|
||||
...at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and live Agent registry.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'List your continuable background subagents by durable id and label. Status is a snapshot of the stored '
|
||||
+ 'record: running means the subagent session is currently live in this process, complete means '
|
||||
+ 'it exists only in storage and a `send_message` starts a new turn on the same conversation. '
|
||||
+ 'The snapshot is not a delivery promise — `send_message` performs the authoritative check and '
|
||||
+ 'may still fail. Children that could not be read are reported as diagnostics instead of being '
|
||||
+ 'silently dropped.',
|
||||
parameters: {},
|
||||
'List your continuable background subagents by durable id and label. Status comes from the live '
|
||||
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
|
||||
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
|
||||
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
|
||||
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
|
||||
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
|
||||
+ 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
|
||||
+ 'candidates for `interrupt_agent` only.',
|
||||
parameters: {
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['children', 'descendants'],
|
||||
description: 'children (default) lists direct children only; descendants walks the complete tree below you.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'array',
|
||||
@@ -53,7 +102,9 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['child'] },
|
||||
id: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true, enum: ['running', 'complete'] },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -63,21 +114,31 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['diagnostic'] },
|
||||
id: { type: 'string', required: true },
|
||||
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (_args, entries) => [{
|
||||
render: (args, entries) => [{
|
||||
type: 'text',
|
||||
text: entries.length === 0
|
||||
? '(no subagents)'
|
||||
: entries.map(entry => entry.kind === 'child'
|
||||
? `${entry.id} [${entry.status}] — ${entry.label}`
|
||||
: `${entry.id} [diagnostic: ${entry.reason}]`).join('\n'),
|
||||
: entries.map((entry) => {
|
||||
// A descendants row always carries its position; children rows
|
||||
// never render it. String() spans the schema-optional shape
|
||||
// without a dead fallback branch.
|
||||
const at = args.scope === 'descendants'
|
||||
? ` parent=${String(entry.parent)} depth=${String(entry.depth)}`
|
||||
: ''
|
||||
return entry.kind === 'child'
|
||||
? `${entry.id} [${entry.status}]${at} — ${entry.label}`
|
||||
: `${entry.id} [diagnostic: ${entry.reason}]${at}`
|
||||
}).join('\n'),
|
||||
}],
|
||||
},
|
||||
async execute(_args, exec) {
|
||||
async execute(args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// Non-agent callers have no session whose children could be listed.
|
||||
@@ -85,21 +146,16 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
// The registry drains started tool bodies, so the scan must observe the
|
||||
// call's signal rather than finish a slow catalog after cancellation.
|
||||
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
|
||||
const visible: ListAgentsEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
visible.push(entry)
|
||||
} else if (entry.mode === 'continuable') {
|
||||
visible.push({
|
||||
kind: 'child',
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
status: entry.activity === 'running' ? 'running' : 'complete',
|
||||
})
|
||||
}
|
||||
if (args.scope === 'descendants') {
|
||||
const entries = await ctx.subagents.listDescendants(parent.id, exec.signal)
|
||||
return entries
|
||||
.map(entry => project(ctx.agents, entry, entry))
|
||||
.filter(entry => entry !== undefined)
|
||||
}
|
||||
return visible
|
||||
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
|
||||
return entries
|
||||
.map(entry => project(ctx.agents, entry))
|
||||
.filter(entry => entry !== undefined)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -12,9 +12,37 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
chunks: StreamChunk[]
|
||||
gate?: Promise<undefined>
|
||||
}
|
||||
|
||||
/** Adapter whose entries can hold a model call open until the test releases it. */
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: GatedEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('GatedAdapter: script exhausted')
|
||||
if (entry.gate) await entry.gate
|
||||
for (const chunk of entry.chunks) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -22,7 +50,7 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
|
||||
@@ -33,9 +61,13 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
return setupWith(new MockAdapter(script))
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
@@ -67,13 +99,19 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
it('registers list_agents once, globally, with no parameters', async () => {
|
||||
it('registers list_agents once, globally, with only the optional scope parameter', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
|
||||
expect(schemas).toHaveLength(1)
|
||||
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props)).toEqual([])
|
||||
const parameters = schemas[0]!.parameters as {
|
||||
properties?: Record<string, { enum?: string[] }>
|
||||
required?: string[]
|
||||
}
|
||||
expect(Object.keys(parameters.properties ?? {})).toEqual(['scope'])
|
||||
expect(parameters.properties?.scope?.enum).toEqual(['children', 'descendants'])
|
||||
expect(parameters.required ?? []).toEqual([])
|
||||
expect(schemas[0]!.description).toContain('send_message')
|
||||
expect(schemas[0]!.description).toContain('interrupt_agent')
|
||||
})
|
||||
|
||||
it('renders the empty result as (no subagents)', async () => {
|
||||
@@ -84,7 +122,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
expect(text(result)).toBe('(no subagents)')
|
||||
})
|
||||
|
||||
it('renders children and diagnostics in array order with the fixed text forms', async () => {
|
||||
it('renders children and diagnostics in array order with registry-derived statuses', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
@@ -94,7 +132,8 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
// Pin the render deterministically past the service: the tool is a thin
|
||||
// adapter, so its fixed text forms are what this test pins.
|
||||
// adapter, so its fixed text forms are what this test pins. Status comes
|
||||
// from the live Agent registry, stubbed per candidate id.
|
||||
const entries: SubagentListEntry[] = [
|
||||
{
|
||||
kind: 'child',
|
||||
@@ -120,14 +159,28 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
activity: 'running',
|
||||
hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child',
|
||||
id: SessionId('waiting-child'),
|
||||
label: 'waiting on descendants',
|
||||
mode: 'continuable',
|
||||
activity: 'running',
|
||||
hasChildren: true,
|
||||
},
|
||||
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
|
||||
]
|
||||
ctx.subagents.listChildren = () => Promise.resolve(entries)
|
||||
const agents = new Map<string, { status: 'running' | 'idle' }>([
|
||||
['running-child', { status: 'running' }],
|
||||
['waiting-child', { status: 'idle' }],
|
||||
])
|
||||
vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\n`
|
||||
+ 'running-child [running] — still working\n'
|
||||
+ 'waiting-child [idle] — waiting on descendants\n'
|
||||
+ 'broken-child [diagnostic: corrupt]',
|
||||
)
|
||||
})
|
||||
@@ -186,7 +239,101 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
it('has the namespace-plugin export shape', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-list-agents')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
expect(tool.inject).toEqual(['tools', 'subagents', 'agents'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('walks the complete descendant tree in pre-order with parent and depth annotations', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const releaseGrandchild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('child'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'waiting branch',
|
||||
request: { prompt: [{ type: 'text', text: 'branch work' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
const grandchild = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'nested leaf',
|
||||
request: { prompt: [{ type: 'text', text: 'leaf work' }], parent: child },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
||||
// The branch finishes its own turn but stays resident waiting on the
|
||||
// grandchild it owns: the live-registry `idle` status.
|
||||
releaseChild.resolve(undefined)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(started.childId)?.status).toBe('idle')
|
||||
}, { timeout: 5_000 })
|
||||
|
||||
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [idle] parent=${parent.id} depth=1 — waiting branch\n`
|
||||
+ `${grandchild.childId} [running] parent=${started.childId} depth=2 — nested leaf`,
|
||||
)
|
||||
|
||||
releaseGrandchild.resolve(undefined)
|
||||
await waitNoActivation(ctx, grandchild.childId)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('omits one-shot intermediates from descendants output while surfacing what they own', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Deterministic service rows: a one-shot intermediate owning a continuable
|
||||
// leaf, plus a positioned diagnostic. The tool filters only the one-shot.
|
||||
ctx.subagents.listDescendants = () => Promise.resolve([
|
||||
{
|
||||
kind: 'child',
|
||||
id: SessionId('one-shot-mid'),
|
||||
label: 'one-shot intermediate',
|
||||
mode: 'one-shot',
|
||||
activity: 'inactive',
|
||||
hasChildren: true,
|
||||
parentId: parent.id,
|
||||
depth: 1,
|
||||
},
|
||||
{
|
||||
kind: 'child',
|
||||
id: SessionId('deep-leaf'),
|
||||
label: 'deep leaf',
|
||||
mode: 'continuable',
|
||||
activity: 'inactive',
|
||||
hasChildren: false,
|
||||
parentId: SessionId('one-shot-mid'),
|
||||
depth: 2,
|
||||
},
|
||||
{
|
||||
kind: 'diagnostic',
|
||||
id: SessionId('broken-node'),
|
||||
reason: 'unavailable',
|
||||
parentId: parent.id,
|
||||
depth: 1,
|
||||
},
|
||||
])
|
||||
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
'deep-leaf [complete] parent=one-shot-mid depth=2 — deep leaf\n'
|
||||
+ `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards the tool cancellation signal to descendant enumeration', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const signal = new AbortController().signal
|
||||
const listDescendants = vi.spyOn(ctx.subagents, 'listDescendants').mockResolvedValue([])
|
||||
|
||||
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent, signal)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(listDescendants).toHaveBeenCalledWith(parent.id, signal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,37 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
chunks: StreamChunk[]
|
||||
gate?: Promise<undefined>
|
||||
}
|
||||
|
||||
/** Adapter whose entries can hold a model call open until the test releases it. */
|
||||
class GatedAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: GatedEntry[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('GatedAdapter: script exhausted')
|
||||
if (entry.gate) await entry.gate
|
||||
for (const chunk of entry.chunks) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -21,7 +49,7 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
|
||||
@@ -32,12 +60,15 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(tool)
|
||||
const adapter = new MockAdapter(script)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
return setupWith(new MockAdapter(script))
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
@@ -177,8 +208,10 @@ describe('dsh-tool-subagent-control', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
@@ -188,3 +221,168 @@ describe('dsh-tool-subagent-control', () => {
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-tool-subagent-control interrupt_agent', () => {
|
||||
it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
|
||||
expect(schemas).toHaveLength(1)
|
||||
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props)).toEqual(['agent_id'])
|
||||
expect(schemas[0]!.description).toContain('current turn')
|
||||
expect(schemas[0]!.description).toContain('send_message')
|
||||
})
|
||||
|
||||
it('interrupts a running direct child with the parent cause, parking its queue', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('held'), gate: releaseFirst.promise },
|
||||
{ chunks: textResponse('parked answer') },
|
||||
{ chunks: textResponse('waking answer') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'long work',
|
||||
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
const queued = await callTool(ctx, 'send_message', {
|
||||
subagent_id: started.childId,
|
||||
message: 'parked follow-up',
|
||||
}, parent)
|
||||
expect(queued.isError).toBe(false)
|
||||
const cancelSpy = vi.spyOn(child, 'cancel')
|
||||
|
||||
const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
|
||||
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
|
||||
releaseFirst.resolve(undefined)
|
||||
await child.whenIdle()
|
||||
// Parked, not resumed: the queued follow-up waits for a waking send.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(child.inbox.nextTurn).toHaveLength(1)
|
||||
|
||||
const waking = await callTool(ctx, 'send_message', {
|
||||
subagent_id: started.childId,
|
||||
message: 'wake up',
|
||||
}, parent)
|
||||
expect(waking.isError).toBe(false)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])
|
||||
})
|
||||
|
||||
it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const releaseGrandchild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('child'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'child',
|
||||
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
const grandchild = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'grandchild',
|
||||
request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
||||
const grandchildAgent = ctx.agents.get(grandchild.childId)!
|
||||
const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
|
||||
|
||||
const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
|
||||
releaseChild.resolve(undefined)
|
||||
releaseGrandchild.resolve(undefined)
|
||||
await waitNoActivation(ctx, grandchild.childId)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('rejects self, sibling, and unrelated callers without touching the target', async () => {
|
||||
const releaseA = Promise.withResolvers<undefined>()
|
||||
const releaseB = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('a'), gate: releaseA.promise },
|
||||
{ chunks: textResponse('b'), gate: releaseB.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const target = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'target',
|
||||
request: { prompt: [{ type: 'text', text: 'a' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const sibling = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'sibling',
|
||||
request: { prompt: [{ type: 'text', text: 'b' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
||||
const targetAgent = ctx.agents.get(target.childId)!
|
||||
const siblingAgent = ctx.agents.get(sibling.childId)!
|
||||
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
|
||||
const cancelSpy = vi.spyOn(targetAgent, 'cancel')
|
||||
|
||||
const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
|
||||
expect(self.isError).toBe(true)
|
||||
expect(text(self)).toContain('cannot interrupt itself')
|
||||
const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
|
||||
expect(fromSibling.isError).toBe(true)
|
||||
expect(text(fromSibling)).toContain('not a live descendant')
|
||||
const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
|
||||
expect(fromStranger.isError).toBe(true)
|
||||
expect(text(fromStranger)).toContain('not a live descendant')
|
||||
expect(cancelSpy).not.toHaveBeenCalled()
|
||||
|
||||
releaseA.resolve(undefined)
|
||||
releaseB.resolve(undefined)
|
||||
await waitNoActivation(ctx, target.childId)
|
||||
await waitNoActivation(ctx, sibling.childId)
|
||||
})
|
||||
|
||||
it('accepts an absent target as a no-op without cold-resuming it', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'settled child',
|
||||
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
|
||||
expect(settled.isError).toBe(false)
|
||||
expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
|
||||
const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
|
||||
expect(unknown.isError).toBe(false)
|
||||
// No cold resume: the settled target never rematerialized.
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('requires a calling agent')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,6 +172,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentDescendantListEntry: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentInterruptAuthority: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
|
||||
@@ -399,10 +399,11 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: {
|
||||
interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionProjections (list_agents catalog rows)'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents and ctx.sessionProjections (list_agents only)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
@@ -414,7 +415,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows are served through the sessionProjections registry).',
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
|
||||
@@ -1155,6 +1155,11 @@
|
||||
"symbol": "SubagentInterruptAuthority",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentDescendantListEntry",
|
||||
"source": "packages/subagent/subagent/src/list-children.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableStart",
|
||||
|
||||
Reference in New Issue
Block a user