fix(subagent): decouple child listing runtime

This commit is contained in:
Dudu-0223
2026-08-02 12:51:09 +08:00
committed by Tianyi Cui
parent 30b9d0464a
commit 2e5c439ecf
17 changed files with 398 additions and 189 deletions
@@ -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: 8d228c1b9af57c0d0054c216dc6ca63cdd4718f2
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: b508d9cde3ba4e0f8451477f1a4e5bbff2df6205
2026-07-22-durable-subagent-catalog-and-list-agents.md: b13e2144bc872a54935e9a6c0d6eef5eeb0e23ef
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e5d89adb8d5a1312447a16904f5631ffc717d458
@@ -33,7 +33,9 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for
The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. This status comes directly from the trace and causes no additional child-log load. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation.
The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract.
The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentService`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list tool catches missing service wiring at plugin load, while another direct service consumer receives the same explicit call-time contract.
`listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing.
This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C be the number of persisted sessions scanned by each persistence listing, and L_i be the size of candidate i's full log. One corpus trace is followed by `sessionQuery.listEvents(childId)` for every candidate. A candidate with no descriptor is omitted, and one with multiple descriptors is diagnosed without another read; only a candidate with exactly one descriptor is loaded again through `sessionQuery.readEvent({ sessionId: childId, seq })`. The read must return the same immutable session header observed by the trace, including the direct-parent relationship, and its target must still be the located descriptor event; a mismatch is per-child corruption. In the persisted-only worst case, each exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a candidate with exactly one descriptor pays those costs twice, while other candidates pay them once. A live candidate similarly takes one detached in-memory snapshot of its full log, or two when its descriptor is read. Session query resolves persisted candidates through the persistence seam's non-mutating `inspect()` read, which returns the valid stored prefix without repairing a torn tail or closing an interrupted turn, so listing is storage-read-only; repair remains the resume path's concern. The first version accepts these repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog, descriptor, or repair event. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically.
@@ -70,6 +72,8 @@ The first version has no child deletion operation. If later product behavior del
**Use the process-local Activation map as a second catalog.** This exposes manager residency but couples a session-discovery query to materialization and settlement, introduces another ordering clock, and makes the same child change candidate source during its lifetime. The first version lists published logical sessions only and treats `SessionRecord.live` as its snapshot status.
**Route listing through the Activation-based continuation manager.** The manager owns residency and requires `agents`, while listing interprets only session-query facts. Routing the read through that manager would impose an unrelated runtime service and make discovery disappear with Activation control, so `SubagentService` owns listing directly.
**Filter by current provider availability.** Provider registration is process-local and may change while the descriptor remains durable. Filtering can hide both a persisted child and a live child even though continuation is provider-independent. Listing therefore establishes durable identity from the descriptor, while `send_message` performs the authoritative delivery-time authority and residency checks.
**Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume.
@@ -84,7 +88,7 @@ The first version has no child deletion operation. If later product behavior del
## Testing
- `packages/subagent/subagent/tests/list-children.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: fresh discovery through a real `startContinuable()` child; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `complete`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract.
- `packages/subagent/subagent/tests/list-children.spec.ts` pins a query-only composition with sessions, `subagents`, and `sessionQuery` but no `agents`, then drives the full real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: fresh discovery through a real `startContinuable()` child; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `complete`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; forwarded trace/exact-read cancellation with stable `CANCELLED` normalization; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract. `packages/subagent/subagent/tests/optional-session-query.spec.ts` rejects eager evaluation of the optional 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 fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal.
- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) pins the model-visible transcript: a background delegation settles, and `list_agents` executes for real against the subagent service, session query, and JSONL persistence, rendering `<id> [complete] — <label>`.
@@ -33,7 +33,9 @@ parent 到 child 的枚举及 `list_agents` 是一个基于持久化 child Sessi
已发布的逻辑记录同时也是状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `complete`。该状态直接来自追踪结果,不会导致额外加载 child 日志。`complete` 表示当前没有存活的 Activation,既不表示执行成功,也不表示 child 已永久关闭;`send_message` 仍可物化另一次 Activation。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活 Agent 仍会显示为 `running`,但 `send_message` 会拒绝,而不会接管它。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法调用时解析这个可选服务;如果服务缺失,该方法会在执行任何工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE``@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents``sessionQuery`。因此,部署可以在不加载会话查询的情况下使用 `send_message`;列表工具会在插件加载时捕获配置错误,而其他直接服务消费方会收到同一项明确的调用时契约。
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents``sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE``@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents``sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具会在插件加载时捕获服务未接入的问题,而其他直接服务消费方会收到同一项明确的调用时契约。
`listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。
这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选都会执行 `sessionQuery.listEvents(childId)`。没有描述符的候选会被排除;含有多个描述符的候选会直接产生 diagnostic,无需再次读取;只有恰好含有一个描述符的候选才会通过 `sessionQuery.readEvent({ sessionId: childId, seq })` 再次加载。此次读取返回的不可变会话 header 必须与追踪时观测到的相同,包括直接 parent 关系,并且读取目标仍必须是先前定位的描述符事件;任何不一致均视为该 child 损坏。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 `persistence.list()`、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);恰好含有一个描述符的候选承担两次这类成本,其他候选只承担一次。存活候选同样会对其完整日志取得一份分离的内存快照;读取其描述符时则会取得两份。会话查询通过持久化 seam 的非变更 `inspect()` 读取解析持久化候选:它返回有效的已存储前缀,既不修复撕裂的尾部,也不关闭中断的 turn,因此列表查询是存储只读操作;修复仍是恢复路径的职责。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会追加任何目录、描述符或修复事件。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。
@@ -70,6 +72,8 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
**使用进程内 Activation map 作为第二个目录。** 这种做法能公开管理器驻留状态,却会让会话发现查询与物化及结算耦合,引入另一套排序时钟,并让同一个 child 在其生命周期内改变候选来源。第一版只列出已经发布的逻辑会话,并将 `SessionRecord.live` 视为其快照状态。
**让列表查询经过基于 Activation 的继续执行管理器。** 管理器负责驻留状态并要求 `agents`,而列表查询只解释会话查询事实。让读取经过该管理器会强制引入无关的运行时服务,并使发现能力随 Activation 控制一同消失,因此列表查询直接由 `SubagentService` 负责。
**按当前提供方可用性过滤。** 提供方注册状态属于进程本地状态,即使描述符仍然持久存在,该状态也可能发生变化。即使继续执行不依赖提供方,过滤仍可能隐藏持久化或存活 child。因此,列表查询根据描述符确立持久化身份,而 `send_message` 在消息送达时执行权威的鉴权与驻留状态检查。
**持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。
@@ -84,7 +88,7 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
## 测试
- `packages/subagent/subagent/tests/list-children.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):通过真实 `startContinuable()` child 的全新发现;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `complete` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。
- `packages/subagent/subagent/tests/list-children.spec.ts` 先固定一个只有会话、`subagents``sessionQuery` 而没有 `agents` 的纯查询组合,再以无密钥方式驱动完整真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):通过真实 `startContinuable()` child 的全新发现;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `complete` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;转发 trace/精确读取取消并稳定归一化为 `CANCELLED`以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。`packages/subagent/subagent/tests/optional-session-query.spec.ts` 会在导入普通 subagent surface 时拒绝对可选运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、childdiagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、无调用 agent 时的拒绝、加载时的 `sessionQuery` 注入,以及 HMR dispose。
- 无密钥 ACP 快照场景 `subagent-list-agents`examples/acp-agent)固定模型可见的转写:一次后台委派结束后,`list_agents` 针对 subagent 服务、会话查询和 JSONL 持久化真实执行,渲染 `<id> [complete] — <label>`
+4 -4
View File
@@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-added` — emit
@@ -811,7 +811,7 @@ A provider became resolvable in the registry.
Types: [SubagentProvider](../core-data-structures/subagent.md)
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:116`](../../packages/subagent/subagent/src/index.ts)
### `subagent/provider-removed` — emit
@@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned.
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:122`](../../packages/subagent/subagent/src/index.ts)
### `subagent/start` — emit
@@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:133`](../../packages/subagent/subagent/src/index.ts)
## `system-prompt/*`
+19 -16
View File
@@ -1949,7 +1949,7 @@ Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/stora
## `ctx.subagents` — `SubagentService`
Named provider registry with one-shot runs and continuable-child operations.
Named provider registry with one-shot runs, durable discovery, and continuable-child operations.
```ts cordis-catalog
/**
@@ -1993,21 +1993,24 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
/**
* Enumerate one session's direct continuable children from the durable,
* live-preferred corpus without loading or resuming an Agent. The lineage
* trace supplies stable candidate order and live status; each candidate is
* then inspected independently for exactly one supported descriptor in its
* own suffix. Listing is storage-read-only: session query resolves persisted
* candidates through the non-mutating `inspect()` read, so no catalog,
* descriptor, or repair event is written. Session-query reads take no signal,
* so cancellation is cooperative: the scan rechecks `signal` before and
* after the initial trace and after every other un-signalled await instead of
* draining a slow or large catalog after the caller has gone.
* @param parentSessionId - parent whose direct children are listed.
* @param signal - caller-owned cancellation observed between query awaits.
* @returns child and diagnostic entries in lineage-trace order.
* Enumerate the parent's direct continuable children from the live-preferred
* session corpus without loading or resuming an Agent. Session query supplies
* lineage, candidate order, event reads, and live state; this service
* interprets descriptors, status, and per-child diagnostics without consulting
* Agent registrations, Activations, or providers.
*
* The trace and exact descriptor read receive `signal`; the full event-list
* read has no signal parameter, so the scan rechecks cancellation around
* every await and between candidates. Query rejections that settle after an
* abort become a stable `SubagentError` with code `CANCELLED`.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded where supported and
* observed around every query await.
* @returns children and per-child diagnostics in stable trace order.
* @throws {@link SubagentError} when session query is unavailable or the
* caller cancels the scan.
*/
async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
/**
* Register a provider under its name. Registration is effect-scoped and HMR
@@ -2045,7 +2048,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.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) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:147`](../../packages/subagent/subagent/src/index.ts)
## `ctx.subprocess` — `SubprocessService` (abstract seam)
+4 -4
View File
@@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:116`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:122`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:133`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
+1 -1
View File
@@ -1160,7 +1160,7 @@ List your background subagents by durable id and label. Status is a snapshot of
}
```
Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)
Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packages/subagent/tool-subagent-control/src/list-agents.ts)
### `send_message`
@@ -882,7 +882,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'subagents',
summary: 'Named provider registry with one-shot runs and continuable-child operations.',
summary: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.',
methods: [
{
signature: 'async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>',
@@ -897,8 +897,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */',
},
{
signature: 'async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate one session\'s direct continuable children from the durable,\n * live-preferred corpus without loading or resuming an Agent. The lineage\n * trace supplies stable candidate order and live status; each candidate is\n * then inspected independently for exactly one supported descriptor in its\n * own suffix. Listing is storage-read-only: session query resolves persisted\n * candidates through the non-mutating `inspect()` read, so no catalog,\n * descriptor, or repair event is written. Session-query reads take no signal,\n * so cancellation is cooperative: the scan rechecks `signal` before and\n * after the initial trace and after every other un-signalled await instead of\n * draining a slow or large catalog after the caller has gone.\n * @param parentSessionId - parent whose direct children are listed.\n * @param signal - caller-owned cancellation observed between query awaits.\n * @returns child and diagnostic entries in lineage-trace order.\n */',
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate the parent\'s direct continuable children from the live-preferred\n * session corpus without loading or resuming an Agent. Session query supplies\n * lineage, candidate order, event reads, and live state; this service\n * interprets descriptors, status, and per-child diagnostics without consulting\n * Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * 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 where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
@@ -43,10 +43,15 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
})
it('attributes each package with a source pointer that names its index', async () => {
it('attributes each harvested tool with its registering plugin source', async () => {
const catalog = await collectToolCatalog()
const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
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({
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
})
})
it('harvests search tools without depending on the generator process PATH', async () => {
@@ -90,7 +95,7 @@ describe('gen-tool-catalog render', () => {
const catalog: ToolCatalog = [
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
sources: { demo: 'packages/demo/tool-demo/src/index.ts' },
requires: ['ctx.tools'],
writes: ['tool/result'],
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e
README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a
README.md: 0c93856bf973781254195b2f3869a54833bf83ac
README.zh.md: aa4c58d01582863e702404d1c8f3d32d3db7f534
+2 -1
View File
@@ -32,6 +32,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct continuable children and per-child diagnostics in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
@@ -88,7 +89,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. 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 [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan without consulting the continuation manager, Agent registrations, Activations, or providers. It forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
## Model Experience
+2 -1
View File
@@ -32,6 +32,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出直接可继续 child 及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
@@ -88,7 +89,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。完整契约见[后台 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/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释只读、实时优先的扫描结果,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。它会把调用方的取消信号转发给可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
## 模型体验
+25 -139
View File
@@ -19,7 +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.
* creation spec and see no handle, turn, or teardown. Direct-child discovery
* independently interprets the optional session-query corpus and does not
* require that continuation runtime.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -36,11 +38,6 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
assertSessionHeadersCompatible,
SessionQueryError,
} from '@deepseek-ai/dsh-session-query'
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
@@ -52,7 +49,6 @@ import type {
SubagentStartRequest,
} from './types.ts'
import { SubagentError } from './error.ts'
import { foldSubagentDescriptor } from './descriptor.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
@@ -62,6 +58,8 @@ import type {
ContinuableStartSpec,
SubagentFollowupOptions,
} from './continuation.ts'
import { listChildren as listSubagentChildren } from './list-children.ts'
import type { SubagentListEntry } from './list-children.ts'
export * from './out-of-process.ts'
export { SubagentRunId } from './types.ts'
@@ -100,30 +98,9 @@ export type {
CoordinatorMessageSource,
SubagentFollowupOptions,
} from './continuation.ts'
export type { SubagentListEntry } from './list-children.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* One direct-child enumeration result. Descriptor-less ordinary children are
* omitted; a per-child inspection failure remains visible as a diagnostic.
*/
export type SubagentListEntry =
| {
readonly kind: 'child'
/** Durable child session id, stable across Activations. */
readonly id: SessionId
/** Durable creation label from the child's descriptor. */
readonly label: string
/** Whether the child is currently live or exists only in persistence. */
readonly status: 'running' | 'complete'
}
| {
readonly kind: 'diagnostic'
/** Traced candidate session id. */
readonly id: SessionId
/** Fixed reason the candidate could not be returned as a child. */
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -165,7 +142,7 @@ declare module 'cordis' {
}
}
/** Named provider registry with one-shot runs and continuable-child operations. */
/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
private continuations: SubagentContinuationManager | undefined
@@ -247,93 +224,25 @@ export class SubagentService extends Service {
}
/**
* Enumerate one session's direct continuable children from the durable,
* live-preferred corpus without loading or resuming an Agent. The lineage
* trace supplies stable candidate order and live status; each candidate is
* then inspected independently for exactly one supported descriptor in its
* own suffix. Listing is storage-read-only: session query resolves persisted
* candidates through the non-mutating `inspect()` read, so no catalog,
* descriptor, or repair event is written. Session-query reads take no signal,
* so cancellation is cooperative: the scan rechecks `signal` before and
* after the initial trace and after every other un-signalled await instead of
* draining a slow or large catalog after the caller has gone.
* @param parentSessionId - parent whose direct children are listed.
* @param signal - caller-owned cancellation observed between query awaits.
* @returns child and diagnostic entries in lineage-trace order.
* Enumerate the parent's direct continuable children from the live-preferred
* session corpus without loading or resuming an Agent. Session query supplies
* lineage, candidate order, event reads, and live state; this service
* interprets descriptors, status, and per-child diagnostics without consulting
* Agent registrations, Activations, or providers.
*
* The trace and exact descriptor read receive `signal`; the full event-list
* read has no signal parameter, so the scan rechecks cancellation around
* every await and between candidates. Query rejections that settle after an
* abort become a stable `SubagentError` with code `CANCELLED`.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded where supported and
* observed around every query await.
* @returns children and per-child diagnostics in stable trace order.
* @throws {@link SubagentError} when session query is unavailable or the
* caller cancels the scan.
*/
async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
const query = this.ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires session query (load a dsh-session-query backend)',
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
)
}
assertListingNotCancelled(signal)
const trace = await query.traceSession(parentSessionId)
assertListingNotCancelled(signal)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const entry = await this.inspectChild(query, parentSessionId, node.session, signal)
// Recheck after the inspection settles, not only inside it: a mapped
// per-child failure during an abort becomes a diagnostic and skips the
// inspection's own checkpoints, and a cancelled scan must not return a
// successful result or start another candidate read.
assertListingNotCancelled(signal)
if (entry !== undefined) entries.push(entry)
}
return entries
}
/** Inspect one traced candidate without materializing its Agent. */
private async inspectChild(
query: SessionQueryService,
parentSessionId: SessionId,
candidate: SessionRecord,
signal?: AbortSignal,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
try {
const records = await query.listEvents(childId)
assertListingNotCancelled(signal)
// Fork seeds replay ancestor events, so only this child's suffix owns its descriptor.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
.map(record => record.seq)
if (descriptorSeqs.length === 0) return undefined
if (descriptorSeqs.length > 1) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
// The length-one branch proves this index exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const seq = descriptorSeqs[0]!
const window = await query.readEvent({ sessionId: childId, seq })
assertListingNotCancelled(signal)
assertSessionHeadersCompatible(window.session, candidate.header)
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
let descriptor: ReturnType<typeof foldSubagentDescriptor>
try {
descriptor = foldSubagentDescriptor([window.target])
} catch {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
return {
kind: 'child',
id: childId,
label: descriptor.label,
status: candidate.live ? 'running' : 'complete',
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error)
if (reason === undefined) throw error
return { kind: 'diagnostic', id: childId, reason }
}
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
/**
@@ -467,26 +376,3 @@ export class SubagentService extends Service {
}
export default SubagentService
/** Stop a cooperative listing scan at its next cancellation checkpoint. */
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
}
}
/** Map isolated session-query failures to the fixed child diagnostic taxonomy. */
function perChildDiagnosticReason(error: unknown): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':
return 'unavailable'
case 'SESSION_QUERY_INVALID_SURFACE':
case 'SESSION_QUERY_SOURCE_CONFLICT':
return 'corrupt'
default:
return undefined
}
}
@@ -0,0 +1,200 @@
/**
* Read-only interpretation of session-query lineage as durable subagent
* children. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state.
*
* @module @deepseek-ai/dsh-subagent
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from './error.ts'
import { foldSubagentDescriptor } from './descriptor.ts'
type SessionQueryRuntime = Pick<
typeof import('@deepseek-ai/dsh-session-query'),
'assertSessionHeadersCompatible' | 'SessionQueryError'
>
/**
* One entry of a {@link listChildren} result in trace candidate order. A valid
* descriptor produces a `child`, a per-child inspection failure produces a
* `diagnostic`, and a descriptor-less ordinary child is omitted. Diagnostics
* are transient query results, never session events or catalog state, and
* never expose model-hidden descriptor content.
*/
export type SubagentListEntry =
| {
readonly kind: 'child'
/** The durable child session id, stable across Activations. */
readonly id: SessionId
/** The durable creation label from the child's descriptor. */
readonly label: string
/**
* Corpus snapshot status: `running` means the logical record is live in
* `ctx.sessions`; `complete` means it exists only in persistence and
* `send_message` may materialize another Activation. Neither encodes a durable
* outcome, and a listed `running` child may still reject delivery as an
* ownership conflict.
*/
readonly status: 'running' | 'complete'
}
| {
readonly kind: 'diagnostic'
/** The traced candidate's session id. */
readonly id: SessionId
/**
* Why the candidate was omitted: `corrupt` for invalid surfaces, header
* conflicts, or malformed/duplicated descriptors; `unsupported` for an
* unknown descriptor version; `unavailable` when the child disappeared or
* its per-child read hit a persistence failure.
*/
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/**
* Interpret one parent's direct session descendants as continuable subagents
* without loading or resuming an Agent.
* @param ctx - context carrying the optional session-query service.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded where supported and
* observed around every query await.
* @returns children and per-child diagnostics in stable trace order.
* @throws {@link SubagentError} when session query is unavailable or
* the caller cancels the scan.
*/
export async function listChildren(
ctx: Context,
parentSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentListEntry[]> {
const query = ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires session query (load a dsh-session-query backend)',
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
)
}
assertListingNotCancelled(signal)
// Keep runtime values behind the listing-only boundary so ordinary
// subagent imports and control operations do not evaluate the optional peer.
const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
assertListingNotCancelled(signal)
const trace = await runListingQuery(
() => query.traceSession(parentSessionId, signal),
signal,
)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const entry = await inspectChild(query, queryRuntime, parentSessionId, node.session, signal)
// Cancellation can race the inspection's last checkpoint or diagnostic
// mapping; do not return success or begin another candidate afterward.
assertListingNotCancelled(signal)
if (entry !== undefined) entries.push(entry)
}
return entries
}
/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
async function inspectChild(
query: SessionQueryService,
queryRuntime: SessionQueryRuntime,
parentSessionId: SessionId,
candidate: SessionRecord,
signal?: AbortSignal,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
try {
const records = await runListingQuery(() => query.listEvents(childId), signal)
// Only the child's own suffix: a fork seed may replay an ancestor's
// descriptor without making the fork itself a continuable subagent.
const seedLength = candidate.header.seedLength ?? 0
const descriptorSeqs = records
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
.map(record => record.seq)
if (descriptorSeqs.length === 0) return undefined
if (descriptorSeqs.length > 1) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
// The length-one branch proves this exact-read sequence exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const seq = descriptorSeqs[0]!
const window = await runListingQuery(
() => query.readEvent({ sessionId: childId, seq }, signal),
signal,
)
queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
let descriptor: ReturnType<typeof foldSubagentDescriptor>
try {
descriptor = foldSubagentDescriptor([window.target])
} catch {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
if (descriptor === undefined) {
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
}
return {
kind: 'child',
id: childId,
label: descriptor.label,
status: candidate.live ? 'running' : 'complete',
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
if (reason === undefined) throw error
return { kind: 'diagnostic', id: childId, reason }
}
}
/** Stop a listing scan at its next cancellation checkpoint. */
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
}
}
/**
* Run one session-query operation between cancellation checkpoints. Query
* implementations may reject with their own abort error after observing the
* forwarded signal; cancellation remains a stable subagent failure.
*/
async function runListingQuery<T>(
operation: () => Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
assertListingNotCancelled(signal)
try {
const result = await operation()
assertListingNotCancelled(signal)
return result
} catch (error: unknown) {
assertListingNotCancelled(signal)
throw error
}
}
/**
* Map a per-child query failure to a fixed diagnostic. Configuration errors
* and unrecognized failures remain operation failures.
*/
function perChildDiagnosticReason(
error: unknown,
SessionQueryError: SessionQueryRuntime['SessionQueryError'],
): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':
return 'unavailable'
case 'SESSION_QUERY_INVALID_SURFACE':
case 'SESSION_QUERY_SOURCE_CONFLICT':
return 'corrupt'
default:
return undefined
}
}
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
@@ -102,6 +102,29 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
}
describe('SubagentService.listChildren', () => {
it('lists through session query without the Activation continuation runtime', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SubagentService)
await ctx.plugin(TestSessionQueryService)
expect(ctx.get('tasks')).toBeUndefined()
expect(ctx.get('agents')).toBeUndefined()
const parentId = SessionId('query-only-parent')
ctx.sessions.create(parentId)
const childId = SessionId('query-only-child')
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
child.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
child.append('subagent/descriptor', descriptorPayload('query-only child'))
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
{ kind: 'child', id: childId, label: 'query-only child', status: 'running' },
])
})
it('fails loud before any work when session query is not loaded', async () => {
const { ctx, parent } = await setup([], { sessionQuery: false })
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
@@ -408,6 +431,49 @@ describe('SubagentService.listChildren', () => {
expect(inspected).toBe(1)
})
it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const entered = Promise.withResolvers<undefined>()
query.traceSession = (_sessionId, signal) => {
entered.resolve(undefined)
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(new Error('query trace aborted'))
}, { once: true })
})
}
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
await entered.promise
controller.abort()
await expect(listing).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'cancelled exact read')
const controller = new AbortController()
const query = ctx.get('sessionQuery')!
const entered = Promise.withResolvers<undefined>()
query.readEvent = (_request, signal) => {
entered.resolve(undefined)
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(new Error('query read aborted'))
}, { once: true })
})
}
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
await entered.promise
controller.abort()
await expect(listing).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
})
it('stops after a per-child read when the signal aborts mid-inspection', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'cancelled mid-read')
@@ -436,8 +502,8 @@ describe('SubagentService.listChildren', () => {
const query = ctx.get('sessionQuery')!
query.listEvents = () => {
// The read fails with a diagnostic-mapped code while the caller aborts:
// the loop's post-inspection checkpoint must fail the scan rather than
// return a one-diagnostic success.
// cancellation normalization must fail the scan rather than return a
// one-diagnostic success.
controller.abort()
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
}
@@ -0,0 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
it('loads ordinary subagent operations without evaluating the optional query package', async () => {
vi.doMock('@deepseek-ai/dsh-session-query', () => {
throw new Error('optional session-query runtime was loaded eagerly')
})
const subagent = await import('../src/index.ts')
expect(subagent.SubagentService).toBeTypeOf('function')
})
})
+32 -6
View File
@@ -124,8 +124,12 @@ interface ToolPackage {
pkg: string
/** The `packages/<group>/<dir>` leaf name — matched by the completeness guard. */
dir: string
/** Repo-relative source path linked from the catalog entry. */
source: string
/**
* Repo-relative implementation source linked per harvested tool. Packages
* whose tools share one plugin may use a string; split plugins map each tool
* name to its own source.
*/
source: string | Readonly<Record<string, string>>
/** Services or owning runtime surfaces the package requires at execution time. */
requires: string[]
/** Session events or other visible state the tools write or affect. */
@@ -386,7 +390,10 @@ const TOOL_PACKAGES: ToolPackage[] = [
{
pkg: '@deepseek-ai/dsh-tool-subagent-control',
dir: 'tool-subagent-control',
source: 'packages/subagent/tool-subagent-control/src/index.ts',
source: {
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.sessionQuery (list_agents only)'],
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
async mount(ctx) {
@@ -464,7 +471,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
/** One package's contribution to the catalog: its schemas plus attribution. */
interface CatalogPackage {
pkg: string
source: string
sources: Readonly<Record<string, string>>
requires: string[]
writes: string[]
shippedNames?: string[]
@@ -519,7 +526,10 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
catalog.push({
pkg: entry.pkg,
source: entry.source,
sources: Object.fromEntries(schemas.map(schema => [
schema.name,
toolSource(entry, schema.name),
])),
requires: entry.requires,
writes: entry.writes,
schemas,
@@ -533,6 +543,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
return catalog
}
/** Resolve one harvested tool to the plugin source that registered it. */
function toolSource(entry: ToolPackage, toolName: string): string {
if (typeof entry.source === 'string') return entry.source
const source = entry.source[toolName]
if (source === undefined) {
throw new Error(
`gen-tool-catalog: ${entry.pkg} has no source mapping for harvested tool ${toolName}`,
)
}
return source
}
/** Render one tool's entry: name, description, JSON-Schema parameters, source. */
function renderTool(schema: ToolSchema, source: string): string[] {
const out = [`### \`${schema.name}\``, '']
@@ -575,7 +597,11 @@ export function render(catalog: ToolCatalog): string {
]
for (const entry of catalog) {
lines.push(`## \`${entry.pkg}\``, '')
for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source))
for (const schema of entry.schemas) {
// Collection validated that every harvested schema has a source.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
lines.push(...renderTool(schema, entry.sources[schema.name]!))
}
if (entry.note) lines.push(entry.note, '')
}
return lines.join('\n')