refactor(subagent): traverse descendant catalogs recursively

This commit is contained in:
Dudu-0223 committed 2026-09-24 15:49:55 +08:00
1 parent 55fadaa601
commit a4ddc28ed5
25 files changed
+354 -952

No files matched your search

@@ -15,5 +15,5 @@
en: 7a3d4b9f69c243d8
zh: 82b03d16ccc94dd2
/agent-note-parent-owned-subagent-catalog-events/consequences:
en: 97f2a8beb039a075
zh: 80efa9f6dd12d438
en: d582273d5102d617
zh: 7f05590581b1df88
@@ -44,6 +44,6 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing recursively calls the same reader in catalog order. Reachable catalog facts supply identity and membership without a global Session scan or a separate child-identity cache path. Unreadable branches remain diagnostics; uncataloged Sessions are outside discovery. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
@@ -44,6 +44,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举按目录顺序递归调用同一读取函数。可达目录事实提供身份和成员关系,无需全局 Session 扫描或独立的子身份缓存路径。不可读分支保留为诊断;未进入目录的 Session 不在发现范围内。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
@@ -15,8 +15,8 @@
en: e7e953783cc4a332
zh: be5d47a503b47170
/agent-note-continuable-subagent-current-turn-interrupt/consequences:
en: 4a7ea28d02c66b63
zh: bad8d6e58b45bd81
en: 8e75516b07cdc842
zh: 10e31033e14241e7
/agent-note-continuable-subagent-current-turn-interrupt/testing:
en: e46413bc89f9238e
zh: 4fb1639ae696e9a4
@@ -41,7 +41,7 @@ The address-only RPC exposes one bit of live residency: an absent target is acce
The Web surface keeps Send and Stop as independent actions for a running continuable child: the client `Session.cancel()` routes Stop through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep their existing primary Send/Stop toggle through `session.cancel`), while Send delivers follow-ups through the busy-Enter setting's mode — Queue by default, Steer when selected — exactly as plain Enter does ([busy Send button](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). A running parent-offline continuable child keeps the default composer with input and Send disabled but Stop 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 `SubagentRuntime.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](../../archived/feature/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.
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 `SubagentRuntime.listDescendants()` recursive catalog walk with `parentId`/`depth` per entry ([parent-catalog note](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns discovery); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
## Testing
@@ -41,7 +41,7 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回
在 Web 侧,正在运行的 continuable child 使用相互独立的 Send 与 Stop 操作:客户端 `Session.cancel()` 将 Stop 路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍通过 `session.cancel` 保留既有的 primary Send/Stop 切换),同时 Send 按繁忙态 Enter 设置的模式投递后续消息——默认 Queue,选择后为 Steer——与 plain Enter 完全一致([繁忙态 Send 按钮](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。parent 离线但仍在运行的 continuable child 保留默认 composer,禁用输入区与 Send,但 Stop 仍然可达;停止后恢复为只读接管界面(周边目录与 composer 约定由 [Web subagent 对话](2026-07-27-web-subagent-conversations.zh.md)拥有)。
`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentRuntime.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId`/`depth`(列表约定由[持久化目录 note](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是 `SubagentRuntime.listDescendants()` 递归目录遍历,每个条目带 `parentId`/`depth`(发现机制由[父目录 note](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
## 测试
+8 -8
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
/subagent:
en: 4f8cfa6e59a1d98f
zh: bd2b2fbabd418018
en: 7ef8cafc02c317b5
zh: 1ccf8273c706b436
/subagent/two-kinds-of-capability-discovered-two-ways:
en: 7be7cbc224948ed1
zh: 6da4f61394ed900e
@@ -12,11 +12,11 @@
en: 37b7876d6f186642
zh: 196a9df4fca5a4fd
/subagent/continuable-children-and-activations:
en: e011e4ac4227f4bc
zh: b15cf25f519a116c
en: 6cc3f373d6ad49b2
zh: dd2cf41fbbc5fbe9
/subagent/durable-enumeration-listchildren-listdescendants-and-their-entries:
en: 3ad5d3831d5c5a5a
zh: 26ca129a41ce7f77
en: a3c7f2a7ac2cdc95
zh: 758b99f246fa5941
/subagent/the-terminal-result-subagentresult:
en: 6070effb5f84f701
zh: 2b8a6980ceb0b7b2
@@ -27,5 +27,5 @@
en: cb05cffdb837b7bc
zh: a53133845d673f0a
/subagent/in-process-backends-permission-depth-and-seed:
en: ed6e94b122384d85
zh: 5961582674b6af69
en: 3290c070c97c284d
zh: ebced35b081ea1c2
+19 -23
View File
@@ -4,7 +4,7 @@ English | [中文](subagent.zh.md)
The subagent seam lets an agent delegate work to a child agent. Like [bash](shell.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor.
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `dsh-subagent-fork-in-process`, `dsh-subagent-acp`, `dsh-subagent-codex`, `dsh-subagent-claude-code`, `dsh-subagent-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager, direct-child discovery through the parent catalog, and complete descendant discovery through the Session corpus. 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), and [the adjacent-Agent messaging Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md); [the archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original list-identity decision.
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `dsh-subagent-fork-in-process`, `dsh-subagent-acp`, `dsh-subagent-codex`, `dsh-subagent-claude-code`, `dsh-subagent-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager, direct-child discovery through the parent catalog, and recursive descendant discovery through parent catalogs. 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), and [the adjacent-Agent messaging Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md); [the archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original list-identity decision.
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)
@@ -262,24 +262,20 @@ interface ContinuableCreateSpec {
The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity).
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; `Session.inheritedEventCount` 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. A seeded cold list skips a cache hint until an authoritative observation supplies that exact cut. 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.
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; `Session.inheritedEventCount` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the 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()`, `listDescendants()`, and their entries
The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed.
`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` retains corpus traversal, child-identity diagnostics, and complete `hasChildren` computation. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
`SubagentRuntime.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:
`SubagentRuntime.listDescendants(rootSessionId)` recursively calls the same catalog reader in stable pre-order, preserving each parent's event order. One-shot and unknown-mode entries remain traversal nodes; unknown modes produce `unsupported` diagnostics. An unreadable child catalog produces `corrupt` or `unavailable` and stops only that branch. Root read failures, missing services or projections, and cancellation reject the listing. Each reachable catalog is observed once and released before the next read; repeated ids and cycles are skipped. Sessions absent from reachable catalogs are not discovered. Each row carries its catalog parent and root-relative depth:
```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.
*/
/** One catalog descendant with its direct parent and edge distance from the requested root. */
type SubagentDescendantListEntry = SubagentListEntry & {
/** Durable direct parent of this candidate in the enumerated tree. */
/** Parent whose catalog contains this child. */
readonly parentId: SessionId
/** Edge distance from the requested root; direct children are `1`. */
readonly depth: number
@@ -465,7 +461,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct
- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap.
- **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics.
`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to recursive catalog listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -585,19 +581,19 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* 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 use the registered
* child identity projection and complete Session corpus.
* @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.
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
* one-shot and unknown-mode children remain traversal nodes. Unknown modes
* produce unsupported diagnostics. Unreadable child catalogs produce corrupt
* or unavailable diagnostics and stop only that branch. Root read failures,
* missing services or projections, and cancellation reject the whole listing.
* Each catalog is observed once and released before the next read. No Agent
* is loaded or resumed; Sessions absent from reachable catalogs are omitted.
* @param rootSessionId - session whose catalog starts descendant discovery.
* @param signal - cancellation forwarded to and checked around each catalog read.
* @returns children and branch diagnostics in parent-catalog pre-order.
* @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels.
* @throws SessionQueryError when the root catalog cannot be read.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
+19 -23
View File
@@ -4,7 +4,7 @@
subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](shell.zh.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.zh.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.zh.md),而非单服务的 bash 执行器。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`dsh-subagent-fork-in-process`、`dsh-subagent-acp`、`dsh-subagent-codex`、`dsh-subagent-claude-code`、`dsh-subagent-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,通过 parent 目录发现直接 child,并通过 Session 语料库发现完整后代。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[相邻 Agent 消息 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md);[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的列表身份决策。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`dsh-subagent-fork-in-process`、`dsh-subagent-acp`、`dsh-subagent-codex`、`dsh-subagent-claude-code`、`dsh-subagent-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,通过 parent 目录发现直接 child,并通过父目录递归发现后代。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[相邻 Agent 消息 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md);[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.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)
@@ -262,24 +262,20 @@ interface ContinuableCreateSpec {
描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。
本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`Session.inheritedEventCount` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。seeded cold list 会跳过 cache hint,直到权威 observation 提供该精确 cut。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。
本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`Session.inheritedEventCount` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。
## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目
模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
`SubagentRuntime.listDescendants(rootSessionId)` 以稳定前序递归调用同一目录读取函数,保留每个父级的事件顺序。一次性和未知模式条目仍是遍历节点;未知模式产生 `unsupported` 诊断。无法读取的子级目录产生 `corrupt` 或 `unavailable`,只停止该分支。根读取失败、服务或投影缺失、取消会使整个查询失败。每个可达目录只观察一次,并在下一次读取前释放;重复 id 和循环引用会被跳过。可达目录中不存在的 Session 不会被发现。每行携带目录中的父级和相对根的深度:
```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.
*/
/** One catalog descendant with its direct parent and edge distance from the requested root. */
type SubagentDescendantListEntry = SubagentListEntry & {
/** Durable direct parent of this candidate in the enumerated tree. */
/** Parent whose catalog contains this child. */
readonly parentId: SessionId
/** Edge distance from the requested root; direct children are `1`. */
readonly depth: number
@@ -469,7 +465,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
- **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。
`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于递归目录列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -589,19 +585,19 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* 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 use the registered
* child identity projection and complete Session corpus.
* @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.
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
* one-shot and unknown-mode children remain traversal nodes. Unknown modes
* produce unsupported diagnostics. Unreadable child catalogs produce corrupt
* or unavailable diagnostics and stop only that branch. Root read failures,
* missing services or projections, and cancellation reject the whole listing.
* Each catalog is observed once and released before the next read. No Agent
* is loaded or resumed; Sessions absent from reachable catalogs are omitted.
* @param rootSessionId - session whose catalog starts descendant discovery.
* @param signal - cancellation forwarded to and checked around each catalog read.
* @returns children and branch diagnostics in parent-catalog pre-order.
* @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels.
* @throws SessionQueryError when the root catalog cannot be read.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
@@ -2865,10 +2865,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>',
description: '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 use the registered child identity projection and complete Session corpus.',
parameters: [{ name: 'rootSessionId', description: 'session whose complete descendant tree is listed.' }, { name: 'signal', description: '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} when listing dependencies are unavailable or the caller cancels.'],
description: 'Recursively list reachable parent catalogs in stable pre-order, preserving each catalog\'s event order. Each row carries its catalog parent and depth; one-shot and unknown-mode children remain traversal nodes. Unknown modes produce unsupported diagnostics. Unreadable child catalogs produce corrupt or unavailable diagnostics and stop only that branch. Root read failures, missing services or projections, and cancellation reject the whole listing. Each catalog is observed once and released before the next read. No Agent is loaded or resumed; Sessions absent from reachable catalogs are omitted.',
parameters: [{ name: 'rootSessionId', description: 'session whose catalog starts descendant discovery.' }, { name: 'signal', description: 'cancellation forwarded to and checked around each catalog read.' }],
returns: 'children and branch diagnostics in parent-catalog pre-order.',
throws: ['{@link SubagentError} when listing dependencies are unavailable or the caller cancels.', 'SessionQueryError when the root catalog cannot be read.'],
},
{
signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>',
+4 -4
View File
@@ -30,8 +30,8 @@
en: 10d0a7e0513bf04b
zh: 0cf2c2d81e85d7a9
/deepseek-ai-dsh-subagent/use-this-package/messaging-interrupting-and-discovering:
en: a38a8772a09b5c0e
zh: a896b41d1aab5ef2
en: ff41ac0e770ed865
zh: 27dcf055badf217d
/deepseek-ai-dsh-subagent/use-this-package/failure-and-recovery:
en: 782aaaf8639c211c
zh: e2a873eece366e74
@@ -42,8 +42,8 @@
en: 26c4eb7592b77d14
zh: c166fb6ee1a1c7d8
/deepseek-ai-dsh-subagent/understand-the-implementation/source-map:
en: 80df1d5b9a6993c9
zh: 6cf5550856c281aa
en: d9afc0d9548afe58
zh: 0e11526b6c001dc6
/deepseek-ai-dsh-subagent/understand-the-implementation/one-shot-flow:
en: 9439bd15ee2129c9
zh: 6c275cf484efa0cf
+2 -2
View File
@@ -60,7 +60,7 @@ One-shot children run once and settle with a single result, plus an optional str
### Messaging, interrupting, and discovering
Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection. `listChildren(parentSessionId, signal?)` owns a live-preferred Session observation and returns the catalog asynchronously without reading child logs. It forwards cancellation and releases the observation after materialization. Materialization preserves parent event order in O(D) time for D facts. Complete descendant discovery retains the Session corpus and child identity projection; neither path loads or resumes a child Agent.
Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection. `listChildren(parentSessionId, signal?)` owns a live-preferred Session observation and returns the catalog asynchronously without reading child logs. It forwards cancellation and releases the observation after materialization. Materialization preserves parent event order in O(D) time for D facts. Descendant discovery recursively reads those child catalogs in parent event order, observing each reachable Session once. It skips branches whose catalogs cannot be read and returns diagnostics for them; neither path loads or resumes a child Agent.
### Failure and recovery
@@ -97,7 +97,7 @@ This section explains how the service is built and where the observable behavior
| [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary |
| [`src/catalog.ts`](src/catalog.ts) | Parent-owned `subagent/catalog` event and chunked host projection |
| [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers |
| [`src/list-children.ts`](src/list-children.ts) | Direct parent-catalog reads and complete descendant-corpus reads |
| [`src/list-children.ts`](src/list-children.ts) | Direct and recursive parent-catalog reads |
| [`src/control.ts`](src/control.ts) | Browser control request validation and stable failure codes |
| [`src/control-types.ts`](src/control-types.ts) | Client-safe catalog row, control requests, receipts, and failures |
| [`src/archive-admission.ts`](src/archive-admission.ts) | The `subagent` family of the Workspace registry's archive admission: running descendants and their parent-cause cancel |
+2 -2
View File
@@ -60,7 +60,7 @@ kind: "package-reference"
### 消息、中断与发现
每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。 直接子级发现读取 parent 自有的 `subagentCatalog` projection。`listChildren(parentSessionId, signal?)` 持有一次优先实时来源的 Session 观察,异步返回目录,不读取子级日志。它转发取消信号,并在物化后释放观察。物化以 O(D) 时间保留 D 条事实的父日志事件顺序。完整后代发现保留 Session 语料库与子级身份 projection;两条路径都不加载或恢复子级 Agent。
每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。 直接子级发现读取 parent 自有的 `subagentCatalog` projection。`listChildren(parentSessionId, signal?)` 持有一次优先实时来源的 Session 观察,异步返回目录,不读取子级日志。它转发取消信号,并在物化后释放观察。物化以 O(D) 时间保留 D 条事实的父日志事件顺序。后代发现按父事件顺序递归读取子级目录,每个可达 Session 观察一次。无法读取目录的分支会被跳过,并返回诊断;两条路径都不加载或恢复子级 Agent。
### 失败与恢复
@@ -97,7 +97,7 @@ kind: "package-reference"
| [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 |
| [`src/catalog.ts`](src/catalog.ts) | parent 自有的 `subagent/catalog` 事件与分块 host projection |
| [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 |
| [`src/list-children.ts`](src/list-children.ts) | 直接 parent 目录读取与完整后代语料读取 |
| [`src/list-children.ts`](src/list-children.ts) | 直接与递归的 parent 目录读取 |
| [`src/control.ts`](src/control.ts) | 浏览器控制请求校验与稳定失败分码 |
| [`src/control-types.ts`](src/control-types.ts) | client-safe 的目录行、控制面请求、回执与失败 |
| [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `subagent` 族:运行中的子孙及其父级取消 |
-5
View File
@@ -73,7 +73,6 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
@@ -102,9 +101,6 @@
"@deepseek-ai/dsh-session-projection": {
"optional": true
},
"@deepseek-ai/dsh-session-projection-cache": {
"optional": true
},
"@deepseek-ai/dsh-session-query": {
"optional": true
},
@@ -136,7 +132,6 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-storage": "workspace:*",
"@deepseek-ai/dsh-storage-domain": "workspace:*",
+12 -16
View File
@@ -1,5 +1,5 @@
/**
* Client-safe complete-descendant rows and browser continuation requests,
* Client-safe recursive catalog rows and browser continuation requests,
* receipts, and failures.
*
* @module @deepseek-ai/dsh-subagent/control-types
@@ -21,13 +21,13 @@ import type {} from '@deepseek-ai/dsh-workspace/types'
*/
export type SubagentPromptRequestId = Branded<'session-request-id'>
/** Shared child fields for complete-descendant listing. */
/** Shared child fields for recursive catalog listing. */
export type SubagentCatalogRow =
& {
/** The durable child session id, stable across Activations. */
readonly id: SessionId
/**
* Whether complete-descendant listing observed a resident Session. This
* Whether recursive catalog listing observed a resident Session. This
* does not encode a durable outcome or guarantee continuation delivery.
*/
readonly activity: 'running' | 'inactive'
@@ -35,25 +35,25 @@ export type SubagentCatalogRow =
| {
/** A terminal one-shot child. */
readonly mode: 'one-shot'
/** Optional durable creation label from the owning catalog or child descriptor. */
/** Optional durable creation label from the parent catalog. */
readonly label?: string
}
| {
/** A resumable conversation. */
readonly mode: 'continuable'
/** Durable creation label from the owning catalog or child descriptor. */
/** Durable creation label from the parent catalog. */
readonly label: string
}
)
/**
* One complete-descendant row. Enumeration may also return diagnostics for
* child identity observations from the complete Session corpus.
* One recursive catalog row. Traversal may also return diagnostics for
* unknown catalog modes or unreadable child catalogs.
*/
export type SubagentListEntry =
| SubagentCatalogRow & {
readonly kind: 'child'
/** Whether complete-corpus enumeration observed a direct child. */
/** Whether the child catalog contains a direct child. */
readonly hasChildren: boolean
}
| {
@@ -61,14 +61,10 @@ export type SubagentListEntry =
/** The candidate's session id. */
readonly id: SessionId
/**
* Why the candidate has no `child` row: `corrupt` for a settled candidate
* whose projection fold served no identity (a missing, malformed, or
* unrecognized-version descriptor — deliberately undistinguished), and
* for any candidate whose log makes a registered unit's fold or schema
* throw (deterministic data damage, contained per child); `unavailable`
* when the candidate's Session observation was absent or transiently
* unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
* union for consumers that route on it.
* `corrupt` means the child catalog read found invalid or conflicting
* Session data; `unavailable` means it could not be read. Either failure
* stops that branch. `unsupported` means the parent catalog records an
* unknown child mode; its readable catalog is still traversed.
*/
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
+14 -14
View File
@@ -18,8 +18,8 @@
* 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 uses the parent catalog; descendant discovery uses the Session
* corpus. Neither read requires the continuation runtime.
* discovery reads the parent catalog; descendant discovery recursively reads
* those child catalogs. Neither read requires the continuation runtime.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -376,19 +376,19 @@ export class SubagentRuntime extends TypertRemoteService {
}
/**
* 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 use the registered
* child identity projection and complete Session corpus.
* @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.
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
* one-shot and unknown-mode children remain traversal nodes. Unknown modes
* produce unsupported diagnostics. Unreadable child catalogs produce corrupt
* or unavailable diagnostics and stop only that branch. Root read failures,
* missing services or projections, and cancellation reject the whole listing.
* Each catalog is observed once and released before the next read. No Agent
* is loaded or resumed; Sessions absent from reachable catalogs are omitted.
* @param rootSessionId - session whose catalog starts descendant discovery.
* @param signal - cancellation forwarded to and checked around each catalog read.
* @returns children and branch diagnostics in parent-catalog pre-order.
* @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels.
* @throws SessionQueryError when the root catalog cannot be read.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]> {
return listSubagentDescendants(this.ctx, rootSessionId, signal)
+52 -321
View File
@@ -1,61 +1,25 @@
/**
* Direct-child discovery from a parent-owned `subagent/catalog` projection,
* plus complete descendant-tree enumeration from the Session corpus. A direct
* listing owns one parent observation and reads no child log. Descendant
* enumeration retains the complete corpus and the child identity projection
* because ordinary Sessions and one-shot children remain traversal nodes.
*
* Direct-child and recursive descendant discovery from parent-owned catalogs.
* Each catalog read releases its Session observation before the next branch.
* @module @deepseek-ai/dsh-subagent
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentListEntry } from './control-types.ts'
import { SubagentError } from './error.ts'
import type { SubagentIdentityProjection } from './projection-types.ts'
import type { SubagentCatalogEntry } from './projection-types.ts'
export type { SubagentListEntry } from './control-types.ts'
/**
* Concurrent cold observations per explicit catalog listing. Current Session
* persistence providers are local; a networked provider must promote this to
* a validated deployment setting.
*/
const COLD_READ_CONCURRENCY = 4
/**
* 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.
*/
/** One catalog descendant with its direct parent and edge distance from the requested root. */
export type SubagentDescendantListEntry = SubagentListEntry & {
/** Durable direct parent of this candidate in the enumerated tree. */
/** Parent whose catalog contains this child. */
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 query: SessionQueryEngine
readonly cache: SessionProjectionCache | undefined
readonly corpus: ReadonlyMap<SessionId, CorpusRecord>
readonly subagentParents: ReadonlySet<SessionId>
}
interface PositionedCandidate {
readonly record: CorpusRecord
readonly parentId: SessionId
readonly depth: number
}
/**
* Read one parent's durable catalog through a live-preferred Session observation.
* @param ctx - context carrying the Session query service.
@@ -90,57 +54,18 @@ export async function listChildren(
}
/**
* 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
* registered child identity projection; no Agent is loaded or resumed.
* @see SubagentRuntime.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} when listing dependencies are unavailable or the caller cancels.
* Walk reachable parent catalogs in stable pre-order without loading Agents.
* @see SubagentRuntime.listDescendants for failure and cancellation semantics.
* @param ctx - context carrying the Session store and query service.
* @param rootSessionId - parent whose catalog starts the traversal.
* @param signal - cancellation checked around each catalog read.
* @returns children and branch diagnostics with catalog parent and depth.
*/
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
// deployment configuration error, never an empty success.
if (projections === undefined) {
throw new SubagentError(
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
)
}
// Strict global read, never the `ctx.sessions` property proxy: the proxy is
// caller-scope bound, so a consumer plugin without its own `sessions`
// injection (the model-facing tool, the API proxy) would throw on access.
const sessions = ctx.get('sessions')
if (sessions === undefined) {
throw new SubagentError(
@@ -148,255 +73,61 @@ async function prepareListing(
'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE',
)
}
assertListingNotCancelled(signal)
const query = ctx.get('sessionQuery')
if (query === undefined) {
throw new SubagentError(
'listing subagents requires the sessionQuery service (load @deepseek-ai/dsh-session-query)',
'SUBAGENT_CONTROL_QUERY_UNAVAILABLE',
)
}
// Optional acceleration only: an absent cache service just means every
// cold candidate takes the authoritative preparation rung, so it carries
// no error code and no configuration check.
const cache = ctx.get('sessionProjectionCache')
let records: Awaited<ReturnType<SessionQueryEngine['listSessions']>>
try {
records = await query.listSessions(signal)
} catch (error: unknown) {
const readChildren = async (id: SessionId): Promise<SubagentCatalogEntry[]> => {
assertListingNotCancelled(signal)
throw error
}
assertListingNotCancelled(signal)
// 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, CorpusRecord>()
for (const record of records) {
const live = sessions.get(record.header.id)
corpus.set(record.header.id, {
header: live?.header ?? record.header,
live,
})
}
const subagentParents = new Set<SessionId>()
for (const record of corpus.values()) {
if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) {
subagentParents.add(record.header.parentSession)
}
}
return { projections, query, 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, query, cache, subagentParents } = listing
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
const coldReads: { index: number; header: SessionHeader }[] = []
candidates.forEach((candidate, index) => {
const childId = candidate.header.id
if (candidate.live === undefined) {
coldReads.push({ index, header: candidate.header })
return
}
// Read only the identity unit. A live child without an identity yet is the
// creation window before the establishing provider appends its descriptor.
let identity: SubagentIdentityProjection | null | undefined
let children: SubagentCatalogEntry[]
try {
identity = projections.snapshot(candidate.live, ['subagent']).values.subagent
} catch {
// A rejecting identity fold is deterministic data damage in this child;
// contain it as one diagnostic instead of failing the whole listing.
rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
return
children = await listChildren(ctx, id, signal)
} catch (error) {
assertListingNotCancelled(signal)
throw error
}
// The unit's serializable no-value sentinel is `null`; `undefined` can
// only mean the key was dropped at a JSON boundary. Both are no value.
if (identity === undefined || identity === null
|| !candidate.live.isOwnSeq(identity.seq)) return
rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
})
// Cold candidates came from the query corpus and are resolved concurrently.
if (coldReads.length > 0) {
const queue = [...coldReads]
await Promise.all(Array.from(
{ length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
async () => {
for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
rows[job.index] = await resolveColdIdentity(
query, cache, job.header,
subagentParents.has(job.header.id), signal,
)
}
},
))
assertListingNotCancelled(signal)
return children
}
assertListingNotCancelled(signal)
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 }))
const stack = (await readChildren(rootSessionId))
.map(entry => ({ entry, parentId: rootSessionId, depth: 1 }))
.reverse()
const visited = new Set<SessionId>([rootSessionId])
while (stack.length > 0) {
// The length guard proves one frame exists.
// oxlint-disable-next-line typescript/no-non-null-assertion
const position = stack.pop()!
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)
}
/**
* Resolve one cold candidate down the remaining ladder: an unseeded durable
* projection-cache row, otherwise one shared Session observation. An absent or transiently failed
* observation is one `unavailable` row retried on the next listing; an observation
* source naming another lifecycle, and a
* settled log the fold cannot identify — or that makes any registered unit
* throw — are final, so they report `corrupt`.
*/
async function resolveColdIdentity(
query: SessionQueryEngine,
cache: SessionProjectionCache | undefined,
header: SessionHeader,
hasChildren: boolean,
signal: AbortSignal | undefined,
): Promise<SubagentListEntry> {
const childId = header.id
// A header deliberately exposes only whether a fork cut exists, not its
// integer. An unseeded lifecycle has the exact cut 0, so its cached
// descriptor is owned at every valid seq; a seeded lifecycle must read the
// body before an identity seq can be classified as inherited or owned.
if (cache !== undefined && !header.isSeeded) {
let cached: SubagentIdentityProjection | null | undefined
const result: SubagentDescendantListEntry[] = []
for (let position = stack.pop(); position !== undefined; position = stack.pop()) {
const { entry, parentId, depth } = position
if (visited.has(entry.id)) continue
visited.add(entry.id)
let children: SubagentCatalogEntry[]
try {
cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent
} catch {
// Unlike the preparation fold below, a throwing cache read renders no
// verdict: the cache is derived data, so its damage (a poisoned stored
// row of ANY unit) silently falls through to the authoritative re-fold.
cached = undefined
children = await readChildren(entry.id)
} catch (error) {
if (error instanceof SubagentError) throw error
const code = error instanceof Error && 'code' in error ? error.code : undefined
result.push({
kind: 'diagnostic', id: entry.id, parentId, depth,
reason: code === 'SESSION_QUERY_CORRUPT_SESSION' || code === 'SESSION_QUERY_SOURCE_CONFLICT'
? 'corrupt' : 'unavailable',
})
continue
}
// An unseeded child's descriptor is owned at every valid seq. Everything
// else falls through to preparation: an absent key and the `null`
// sentinel, whose verdict belongs to the authoritative re-fold, not to a
// derived row.
if (cached !== undefined && cached !== null) {
return childRow(childId, cached, 'inactive', hasChildren)
if (entry.mode === 'unknown') {
result.push({ kind: 'diagnostic', id: entry.id, parentId, depth, reason: 'unsupported' })
} else {
const { createdAt: _createdAt, ...identity } = entry
result.push({
...identity, kind: 'child', parentId, depth,
activity: sessions.get(entry.id) === undefined ? 'inactive' : 'running',
hasChildren: children.length > 0,
})
}
// Catalog event order defines siblings; the stack visits the first one next.
for (const child of [...children].reverse()) {
stack.push({ entry: child, parentId: entry.id, depth: depth + 1 })
}
}
assertListingNotCancelled(signal)
let observation: SessionObservation
try {
observation = await query.observeSession(childId, {
...(signal === undefined ? {} : { signal }),
})
} catch (error: unknown) {
// Per-child isolation: durable corruption is stable; absence and backend
// failures remain retryable. Either way, the listing itself still succeeds.
assertListingNotCancelled(signal)
return {
kind: 'diagnostic',
id: childId,
reason: sessionQueryCode(error) === 'SESSION_QUERY_CORRUPT_SESSION'
|| sessionQueryCode(error) === 'SESSION_QUERY_SOURCE_CONFLICT'
? 'corrupt'
: 'unavailable',
}
}
using ownedObservation = observation
assertListingNotCancelled(signal)
// A session id names a slot, not a lifecycle: a child deleted and
// re-published under another owner between the enumeration and this read
// must not leak into the old parent's listing.
if (!sameLifecycle(ownedObservation.header, header)) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
const identity = ownedObservation.projections?.values.subagent
if (identity === undefined || identity === null
|| identity.seq < ownedObservation.inheritedEventCount) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
return childRow(childId, identity, 'inactive', hasChildren)
return result
}
/** Materialize one served identity as its child row. */
function childRow(
id: SessionId,
identity: SubagentIdentityProjection,
activity: 'running' | 'inactive',
hasChildren: boolean,
): SubagentListEntry {
return identity.mode === 'one-shot'
? {
kind: 'child',
id,
mode: 'one-shot',
...identity.label !== undefined ? { label: identity.label } : {},
activity,
hasChildren,
}
: {
kind: 'child',
id,
mode: 'continuable',
label: identity.label,
activity,
hasChildren,
}
}
/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
const LIFECYCLE_WITNESS_KEYS = [
'version', 'id', 'createdAt', 'cwd', 'parentSession', 'isSeeded', 'delegationDepth',
'origin', 'agentPreset',
] as const
/** Whether an inspected log still belongs to the enumerated lifecycle. */
function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
}
/** Stop a listing at its next cancellation checkpoint. */
/** Stop the complete traversal when its caller cancels. */
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
}
}
function sessionQueryCode(error: unknown): unknown {
return error instanceof Error && 'code' in error ? error.code : undefined
}
@@ -3,26 +3,16 @@ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSyn
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore, { SessionLogOffset, SessionSeq, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { generationLogPath } from '../../../session/session-persistence-jsonl/src/format.ts'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
import Storage from '@deepseek-ai/dsh-storage'
import {
apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
} from '@deepseek-ai/dsh-storage-json'
import {
apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
} from '@deepseek-ai/dsh-storage-domain'
import SubagentRuntime, {
SUBAGENT_DESCRIPTOR_VERSION,
} from '@deepseek-ai/dsh-subagent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentCatalogEntry } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -32,21 +22,18 @@ import { seedStoredSession } from './persistence-helpers.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
const projectionCacheDisposers: Array<() => Promise<void>> = []
const persistenceDisposers: Array<() => Promise<void>> = []
const projCacheRoots: string[] = []
afterEach(async () => {
await Promise.all(projectionCacheDisposers.splice(0).map(dispose => dispose()))
vi.restoreAllMocks()
await Promise.all(persistenceDisposers.splice(0).map(dispose => dispose()))
for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true })
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
})
/** Boot the continuable stack with real JSONL session persistence. */
async function setup(
script: Script,
options: { projectionCache?: boolean; compression?: 'none' | 'zstd' } = {},
options: { compression?: 'none' | 'zstd' } = {},
) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
@@ -58,17 +45,6 @@ async function setup(
})
persistenceDisposers.push(() => persistence.dispose())
await ctx.plugin(AgentLoop, { agents: [] })
if (options.projectionCache === true) {
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-projcache-'))
projCacheRoots.push(root)
// The cache opens its domain through the storage stack; the json backend
// lands it under this tmp root.
await ctx.plugin(Storage)
await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
const cache = await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
projectionCacheDisposers.push(() => cache.dispose())
}
await ctx.plugin(TestSessionQuery)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
@@ -104,85 +80,6 @@ async function startChild(
return started.childId
}
/** Author one persisted child session directly against the persistence backend. */
async function authorChild(
ctx: Context,
id: string,
header: Partial<SessionHeader>,
events: SessionEvent[],
inheritedEventCount?: number,
): Promise<SessionId> {
const sessionId = SessionId(id)
const committed = inheritedEventCount === undefined
? events
: [
...events.slice(0, inheritedEventCount),
{
type: 'session/end-seed',
seq: SessionSeq(inheritedEventCount),
time: events[inheritedEventCount]?.time ?? events[inheritedEventCount - 1]?.time ?? 1,
data: { inherited: true },
} as SessionEvent,
...events.slice(inheritedEventCount),
].map((event, seq) => ({ ...event, seq: SessionSeq(seq) }))
await seedStoredSession(ctx.sessionPersistence, {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
isSeeded: inheritedEventCount !== undefined,
...header,
}, committed, inheritedEventCount === undefined ? undefined : SessionLogOffset(inheritedEventCount))
return sessionId
}
/** Minimal complete-turn child log with one descriptor payload. */
function childEvents(descriptor: unknown): SessionEvent[] {
return [
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: SessionSeq(1),
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'subagent/descriptor', seq: SessionSeq(2), time: 3, data: descriptor },
{ type: 'turn/end', seq: SessionSeq(3), time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
}
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
return { version, mode: 'continuable' as const, provider: 'spawn', label }
}
/**
* Serve one cold session's point read (the open handle) with a transformed
* header while the enumeration listing keeps reporting the stored original —
* the re-published-lifecycle shape the sameLifecycle check exists for.
*/
function mutateStoredHeader(
ctx: Context,
target: SessionId,
mutate: (meta: SessionHeader) => SessionHeader,
): void {
const originalOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence)
ctx.sessionPersistence.open = async (sessionId, access, options) => {
const handle = await originalOpen(sessionId, access, options)
if (sessionId !== target) return handle
return {
id: handle.id,
access: handle.access,
header: mutate(handle.header),
inheritedEventCount: handle.inheritedEventCount,
read: (offset, length, readOptions) => handle.read(offset, length, readOptions),
append: (events, appendOptions) => handle.append(events, appendOptions),
flush: flushOptions => handle.flush(flushOptions),
close: () => handle.close(),
[Symbol.asyncDispose]: () => handle[Symbol.asyncDispose](),
}
}
}
describe('SubagentRuntime.listChildren', () => {
it('lists historical children through a migrated parent while preserving V3 files', async () => {
const { ctx, root } = await setup([], { compression: 'none' })
@@ -357,434 +254,169 @@ describe('SubagentRuntime.listChildren', () => {
})
/** Add discovery facts without requiring a child descriptor. */
function catalog(parent: Session, children: SubagentCatalogEntry[]): void {
for (const { id, createdAt, ...identity } of children) {
parent.append('subagent/catalog', { version: 1, childId: id, childCreatedAt: createdAt, ...identity })
}
}
function child(id: string, mode: 'one-shot' | 'continuable' | 'unknown' = 'continuable'): SubagentCatalogEntry {
return { id: SessionId(id), createdAt: 1, mode, label: id }
}
describe('SubagentRuntime.listDescendants', () => {
it('flattens the complete tree in stable pre-order with verified parent and depth', async () => {
it('walks parent catalogs in event order without enumerating unrelated Sessions or reading descriptors', 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 branch = ctx.sessions.create(SessionId('branch'))
const leaf = ctx.sessions.create(SessionId('leaf'))
const sibling = ctx.sessions.create(SessionId('sibling'))
ctx.sessions.create(SessionId('unrelated'))
catalog(parent.session, [{ ...child('branch'), createdAt: 20 }, { ...child('sibling', 'one-shot'), createdAt: 10 }])
catalog(branch, [child('leaf')])
const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions')
const observeSession = vi.spyOn(ctx.sessionQuery, 'observeSession')
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,
},
expect(await ctx.subagents.listDescendants(parent.id)).toEqual([
{ kind: 'child', id: branch.id, mode: 'continuable', label: 'branch', activity: 'running', hasChildren: true, parentId: parent.id, depth: 1 },
{ kind: 'child', id: leaf.id, mode: 'continuable', label: 'leaf', activity: 'running', hasChildren: false, parentId: branch.id, depth: 2 },
{ kind: 'child', id: sibling.id, mode: 'one-shot', label: 'sibling', activity: 'running', hasChildren: false, parentId: parent.id, depth: 1 },
])
expect(listSessions).not.toHaveBeenCalled()
expect(observeSession.mock.calls.map(([id]) => id)).toEqual([parent.id, branch.id, leaf.id, sibling.id])
})
it('returns an empty result when the root has no descendants', async () => {
it('reads a cold child catalog without loading an Agent or requiring a descriptor', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([])
})
it('omits a live creation-window candidate while continuing through its subtree', async () => {
const { ctx, parent } = await setup([])
const bareId = SessionId('live-creation-window')
const bare = ctx.sessions.create(bareId, {
meta: { createdAt: 1, parentSession: parent.id, origin: 'subagent' },
})
bare.append('turn/start', { turn: 1 })
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaaf', {
parentSession: bareId,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the creation window')))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: below, label: 'below the creation window', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: bareId, depth: 2,
}])
})
it('contains a corrupt parent cycle without revisiting the requested root', async () => {
const { ctx } = await setup([])
const rootId = SessionId('cycle-root')
const nodeId = SessionId('cycle-node')
await authorChild(ctx, rootId, {
parentSession: nodeId,
createdAt: 2,
}, childEvents(descriptorPayload('ordinary cycle root')))
await authorChild(ctx, nodeId, {
parentSession: rootId,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('cycle child')))
await expect(ctx.subagents.listDescendants(rootId)).resolves.toEqual([{
kind: 'child', id: nodeId, label: 'cycle child', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: rootId, depth: 1,
}])
})
it('walks a deeply nested ordinary-session chain without consuming the call stack', { timeout: 20_000 }, 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', { timeout: 20_000 }, 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: SessionSeq(0), time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: SessionSeq(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,
},
const id = SessionId('cold-child')
await seedStoredSession(ctx.sessionPersistence, {
version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false,
origin: 'subagent', parentSession: parent.id,
}, [])
catalog(parent.session, [{ id, createdAt: 1, mode: 'one-shot' }])
expect(await ctx.subagents.listDescendants(parent.id)).toEqual([
{ kind: 'child', id, mode: 'one-shot', activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1 },
])
expect(ctx.agents.get(id)).toBeUndefined()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('keeps traversing below a corrupt intermediate and positions its diagnostic', async () => {
it('traverses one-shot and unknown-mode catalog entries to reach continuable children', 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,
},
])
const once = ctx.sessions.create(SessionId('once'))
const unknown = ctx.sessions.create(SessionId('unknown'))
const leaf = ctx.sessions.create(SessionId('leaf'))
catalog(parent.session, [child('once', 'one-shot')])
catalog(once, [child('unknown', 'unknown')])
catalog(unknown, [child('leaf')])
const rows = await ctx.subagents.listDescendants(parent.id)
expect(rows).toHaveLength(3)
expect(rows[0]).toMatchObject({ kind: 'child', id: once.id, hasChildren: true })
expect(rows[1]).toEqual({ kind: 'diagnostic', id: unknown.id, reason: 'unsupported', parentId: once.id, depth: 2 })
expect(rows[2]).toMatchObject({ kind: 'child', id: leaf.id, parentId: unknown.id, depth: 3 })
})
it('fails loud when descendant enumeration lacks the Session store or query service', async () => {
const withoutStore = new Context()
await withoutStore.plugin(SessionProjectionRegistry)
await withoutStore.plugin(SubagentRuntime)
await expect(withoutStore.subagents.listDescendants(SessionId('no-store-parent'))).rejects.toMatchObject({
code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE',
})
const withoutQuery = new Context()
await withoutQuery.plugin(SessionStore)
await withoutQuery.plugin(SessionProjectionRegistry)
await withoutQuery.plugin(SubagentRuntime)
await expect(withoutQuery.subagents.listDescendants(SessionId('no-query-parent'))).rejects.toMatchObject({
code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE',
})
})
it('propagates a descendant corpus listing failure', async () => {
it('visits each catalog once when persisted membership repeats or cycles', async () => {
const { ctx, parent } = await setup([])
const failure = new Error('backend listing failed')
vi.spyOn(ctx.sessionQuery, 'listSessions').mockRejectedValue(failure)
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toBe(failure)
const branch = ctx.sessions.create(SessionId('branch'))
const leaf = ctx.sessions.create(SessionId('leaf'))
catalog(parent.session, [child('branch'), child('leaf')])
catalog(branch, [child('leaf')])
catalog(leaf, [child('parent'), child('branch')])
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const rows = await ctx.subagents.listDescendants(parent.id)
expect(rows.map(row => row.id)).toEqual([branch.id, leaf.id])
expect(observe).toHaveBeenCalledTimes(3)
})
it('maps an aborted descendant corpus listing to the stable cancellation error', async () => {
it('returns no descendants from an empty root catalog', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
vi.spyOn(ctx.sessionQuery, 'listSessions').mockImplementation(() => {
controller.abort()
return Promise.reject(new Error('backend listing aborted'))
})
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toMatchObject({
code: 'CANCELLED',
})
})
it('contains a live descendant projection failure and orders sibling ties by id', async () => {
const { ctx, parent } = await setup([])
const laterId = SessionId('live-projection-z')
const earlierId = SessionId('live-projection-a')
for (const id of [laterId, earlierId]) {
const child = ctx.sessions.create(id, {
meta: { parentSession: parent.id, origin: 'subagent', createdAt: 5 },
})
child.append('subagent/descriptor', descriptorPayload(String(id)))
}
const snapshot = ctx.sessionProjections.snapshot.bind(ctx.sessionProjections)
vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation((session, keys) => {
if (session.id === laterId) throw new Error('projection failed')
return snapshot(session, keys)
})
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{
kind: 'child', id: earlierId, label: String(earlierId), mode: 'continuable',
activity: 'running', hasChildren: false, parentId: parent.id, depth: 1,
},
{ kind: 'diagnostic', id: laterId, reason: 'corrupt', parentId: parent.id, depth: 1 },
])
})
it('serves a valid own-suffix descendant identity from the projection cache', async () => {
const { ctx, parent } = await setup([], { projectionCache: true })
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('disk label')))
ctx.sessionProjectionCache.cachedSnapshot = () => ({
asOfSeq: SessionSeq(2),
values: { subagent: { mode: 'one-shot', seq: SessionSeq(2) } },
})
const open = vi.spyOn(ctx.sessionPersistence, 'open')
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, mode: 'one-shot', activity: 'inactive', hasChildren: false,
parentId: parent.id, depth: 1,
}])
expect(open).not.toHaveBeenCalled()
expect(await ctx.subagents.listDescendants(parent.id)).toEqual([])
})
it.each([
['absent', () => ({ asOfSeq: SessionSeq(0), values: {} })],
['null', () => ({ asOfSeq: SessionSeq(0), values: { subagent: null } })],
['inherited', () => ({
asOfSeq: SessionSeq(1),
values: { subagent: { mode: 'continuable' as const, label: 'ancestor', seq: SessionSeq(1) } },
})],
])('falls back to the authoritative descendant fold for a %s cached identity', async (_name, cachedSnapshot) => {
const { ctx, parent } = await setup([], { projectionCache: true })
const childId = await authorChild(ctx, `cached-fallback-${_name}`, {
parentSession: parent.id,
origin: 'subagent',
}, _name === 'inherited'
? [
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'subagent/descriptor', seq: SessionSeq(2), time: 3, data: descriptorPayload('own label') },
] as SessionEvent[]
: childEvents(descriptorPayload('own label')),
_name === 'inherited' ? 2 : undefined)
ctx.sessionProjectionCache.cachedSnapshot = cachedSnapshot
const open = vi.spyOn(ctx.sessionPersistence, 'open')
await expect(ctx.subagents.listDescendants(parent.id, testSignal)).resolves.toEqual([{
kind: 'child', id: childId, label: 'own label', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1,
}])
expect(open).toHaveBeenCalledTimes(1)
})
it('falls back to the authoritative descendant fold when a cache read throws', async () => {
const { ctx, parent } = await setup([], { projectionCache: true })
const childId = await authorChild(ctx, 'cache-read-failure', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('recovered')))
ctx.sessionProjectionCache.cachedSnapshot = () => { throw new Error('poisoned cache row') }
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, label: 'recovered', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1,
}])
})
it.each([
['SESSION_QUERY_CORRUPT_SESSION', 'corrupt'],
['SESSION_QUERY_SOURCE_CONFLICT', 'corrupt'],
['OTHER', 'unavailable'],
] as const)('maps a %s cold descendant read failure to %s', async (code, reason) => {
['corrupt', Object.assign(new Error('corrupt branch'), { code: 'SESSION_QUERY_CORRUPT_SESSION' })],
['corrupt', Object.assign(new Error('conflicting branch'), { code: 'SESSION_QUERY_SOURCE_CONFLICT' })],
['unavailable', new Error('unavailable branch')],
['unavailable', 'unavailable branch'],
] as const)('contains a %s branch read failure and continues with siblings', async (reason, failure) => {
const { ctx, parent } = await setup([])
const childId = await authorChild(ctx, `cold-failure-${code}`, {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('unreadable')))
const failure = Object.assign(new Error('cold read failed'), { code })
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(failure)
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'diagnostic', id: childId, reason, parentId: parent.id, depth: 1,
}])
})
it('maps a non-Error cold descendant rejection to unavailable', async () => {
const { ctx, parent } = await setup([])
const childId = await authorChild(ctx, 'cold-non-error-failure', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('unreadable')))
vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(() =>
// A backend can violate the Error rejection convention at this durable boundary.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises that non-Error boundary
Promise.reject('cold read failed'))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'diagnostic', id: childId, reason: 'unavailable', parentId: parent.id, depth: 1,
}])
})
it('maps a cold descendant failure racing cancellation to cancellation', async () => {
const { ctx, parent } = await setup([])
await authorChild(ctx, 'cold-cancelled-failure', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('cancelled')))
const controller = new AbortController()
vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(() => {
controller.abort()
return Promise.reject(new Error('cold read failed'))
})
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toMatchObject({
code: 'CANCELLED',
})
})
it('maps cancellation after a successful cold descendant read to cancellation', async () => {
const { ctx, parent } = await setup([])
await authorChild(ctx, 'cold-cancelled-success', {
parentSession: parent.id,
origin: 'subagent',
}, childEvents(descriptorPayload('cancelled')))
const controller = new AbortController()
const branch = ctx.sessions.create(SessionId('branch'))
const hidden = ctx.sessions.create(SessionId('hidden'))
const sibling = ctx.sessions.create(SessionId('sibling'))
catalog(parent.session, [child('branch'), child('sibling')])
catalog(branch, [child('hidden')])
const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (id, options) => {
const observation = await observe(id, options)
controller.abort()
return observation
})
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toMatchObject({
code: 'CANCELLED',
vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id, options) => {
if (id === branch.id) {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Persistence failures can reject with non-Error values.
return Promise.reject(failure)
}
return observe(id, options)
})
const rows = await ctx.subagents.listDescendants(parent.id)
expect(rows[0]).toEqual({ kind: 'diagnostic', id: branch.id, parentId: parent.id, depth: 1, reason })
expect(rows[1]).toMatchObject({ kind: 'child', id: sibling.id })
expect(rows.some(row => row.id === hidden.id)).toBe(false)
})
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')
it('reports a missing child as unavailable', async () => {
const { ctx, parent } = await setup([])
catalog(parent.session, [child('missing')])
expect(await ctx.subagents.listDescendants(parent.id)).toEqual([
{ kind: 'diagnostic', id: SessionId('missing'), parentId: parent.id, depth: 1, reason: 'unavailable' },
])
})
it('propagates root read failures and missing catalog projections', async () => {
const { ctx, parent } = await setup([])
const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
const failure = new Error('root unavailable')
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(failure)
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toBe(failure)
const branch = ctx.sessions.create(SessionId('branch'))
catalog(parent.session, [child('branch')])
vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id, options) =>
observe(id, id === branch.id ? { ...options, projectionMode: 'none' } : options),
)
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' })
})
it('fails before reading when the Session store or query service is absent', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' })
await ctx.plugin(SessionStore)
await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE' })
})
it('cancels before observing the root', async () => {
const { ctx, parent } = await setup([])
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()
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toMatchObject({ code: 'CANCELLED' })
expect(observe).not.toHaveBeenCalled()
})
it('fails loud when the projection registry is not mounted', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SubagentRuntime)
await expect(ctx.subagents.listDescendants(SessionId('no-projections-root'))).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
)
})
it('verifies a cold candidate still belongs to its enumerated lifecycle', async () => {
it.each([false, true])('cancels the traversal when a branch read settles (failure: %s)', async (reject) => {
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')))
// The exact read reports a different durable parent than enumeration did.
mutateStoredHeader(ctx, childId, meta => ({ ...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 },
])
const branch = ctx.sessions.create(SessionId('branch'))
catalog(parent.session, [child('branch')])
const controller = new AbortController()
const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
const observed = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (id, options) => {
if (id !== branch.id) return observe(id, options)
const observation = await observe(id, options)
controller.abort()
if (reject) {
observation[Symbol.dispose]()
throw new Error('read failed during cancellation')
}
return observation
})
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toMatchObject({ code: 'CANCELLED' })
expect(observed.mock.calls.every(([, options]) => options?.signal === controller.signal)).toBe(true)
})
})
-3
View File
@@ -53,9 +53,6 @@
{
"path": "../../session/session-projection"
},
{
"path": "../../session/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},
@@ -27,8 +27,8 @@
en: 01dd41fe11f294d7
zh: f45a2e4073c0b206
/deepseek-ai-dsh-tool-subagent-control/use-this-package/list-agents:
en: eaca9851b5b5e4b8
zh: cf654cfa670a1d4b
en: db2979a018d7896b
zh: 795f2c6de7303977
/deepseek-ai-dsh-tool-subagent-control/understand-the-implementation:
en: c5cdf01a78c990f2
zh: 53c180d726ce4502
@@ -39,8 +39,8 @@
en: 4c7698ccea49a60b
zh: fbf129ff583e6ab9
/deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/listing-projection:
en: 44e4e03908573520
zh: ca799bde1cfbe273
en: a115fea03db8fd95
zh: 355488590648a82e
/deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/source-map:
en: a2a918cbad1a8313
zh: 5bcddba1b2bdd26e
@@ -54,7 +54,7 @@ Stops only the target's current turn: queued messages stay parked until a later
### list_agents
Lists the continuable children below the calling agent: `children` (default) reads direct children from the parent catalog without opening child logs; `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running` or `inactive`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics only in `descendants` scope.
Lists the continuable children below the calling agent: `children` (default) reads direct children from the parent catalog without opening child logs; `descendants` recursively reads child catalogs in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running` or `inactive`. One-shot children are omitted from output but their catalogs remain traversal nodes. Unknown modes and unreadable child catalogs appear as diagnostics only in `descendants` scope.
-----
@@ -76,7 +76,7 @@ The tool forwards its execution signal, which owns admission only until inbox ac
### Listing projection
`list_agents` derives the root id from the calling agent, reads the service catalog without a cursor, refines each candidate's status through the live Agent registry, and omits one-shot children because they cannot accept `send_message`. Diagnostics keep their positions in the descendants scope and never expose descriptor contents.
`list_agents` derives the root id from the calling agent, reads the service catalog without a cursor, refines each candidate's status through the live Agent registry, and omits one-shot children because they cannot accept `send_message`. Descendant traversal preserves each parent catalog’s event order. Unknown modes produce diagnostics while their catalogs remain traversable; unreadable catalogs produce diagnostics and stop that branch. Descendants absent from reachable catalogs cannot be discovered.
### Source map
@@ -54,7 +54,7 @@ kind: "package-reference"
### list_agents
列出调用方 agent 下方的可继续子级:`children`(默认)从父目录读取直接子级,不打开子日志;`descendants` 按稳定前序遍历整棵树,并为每个条目标注其持久化直接父级会话 ID 与深度。状态来自在线 Agent 注册表——`running` 或 `inactive`。一次性子级因无法接受 `send_message` 而被有意排除,无法读取的候选项仅在 `descendants` 作用域中以诊断信息呈现。
列出调用方 agent 下方的可继续子级:`children`(默认)从父目录读取直接子级,不打开子日志;`descendants` 按稳定前序递归读取子级目录,并为每个条目标注其持久化直接父级会话 ID 与深度。状态来自在线 Agent 注册表——`running` 或 `inactive`。一次性子级不出现在输出中,但其目录仍是遍历节点。未知模式与无法读取的子级目录仅在 `descendants` 作用域中以诊断信息呈现。
-----
@@ -76,7 +76,7 @@ kind: "package-reference"
### 列表投影
`list_agents` 从调用 agent 推导根 id,不使用 cursor 读取服务目录,通过在线 Agent 注册表细化每个候选的状态,并省略无法接受 `send_message` 的一次性子级。diagnostic 在 descendants scope 中保留其位置,且绝不暴露描述符内容。
`list_agents` 从调用 agent 推导根 id,不使用 cursor 读取服务目录,通过在线 Agent 注册表细化每个候选的状态,并省略无法接受 `send_message` 的一次性子级。后代遍历保留每个父目录的事件顺序。未知模式产生诊断,但其目录仍可遍历;无法读取的目录产生诊断,并停止该分支。可达目录中不存在的后代无法被发现。
### 源码地图
-3
View File
@@ -12044,9 +12044,6 @@ importers:
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:*
version: link:../../session/session-projection-cache
'@deepseek-ai/dsh-session-query':
specifier: workspace:*
version: link:../../session-query/session-query
@@ -0,0 +1,18 @@
{"type":"session","version":4,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:13}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:14}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:16}}"},"usage":{"inputTokens":10,"outputTokens":3},"stream":[{"type":"chunk","time":1788699463914,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788699463914,"index":0,"dt":[],"texts":["CHILD_OK"]},{"type":"chunk","time":1788699463915,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},{"type":"chunk","time":1788699463915,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}},{"type":"chunk","time":1788699463915,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,44 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1789000000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Call the subagent tool once","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880389574,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880389574,"index":0,"dt":[],"id":"call_bg_start","name":"subagent","args":["{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"]},{"type":"chunk","time":1788880389574,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}},{"type":"chunk","time":1788880389574,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880389574,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}
{"type":"subagent/catalog","data":{"version":0,"childId":"{{session:2}}","childCreatedAt":1789000001000,"mode":"continuable","label":"Reply with CHILD_OK"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_bg_start"},"toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent {{session:2}}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880389609,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880389609,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":1788880389609,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":1788880389609,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880389609,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880389644,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880389644,"index":0,"dt":[],"texts":["SUBAGENT_SETTLED_NOTED"]},{"type":"chunk","time":1788880389644,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}},{"type":"chunk","time":1788880389644,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880389644,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to {{session:2}}. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"}]}}
{"type":"turn/start","data":{"turn":3}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":3,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once with scope set to descendants and observe the subagent you started. Then call interrupt_agent once with agent_id set to {{session:2}}. Then reply with the single word DONE. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880389662,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788880389662,"index":0,"dt":[],"id":"call_list","name":"list_agents","args":["{\"scope\":\"descendants\"}"]},{"type":"chunk","time":1788880389662,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}}},{"type":"chunk","time":1788880389662,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880389662,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":3,"step":1,"callId":"call_list","name":"list_agents","arguments":"{\"scope\":\"descendants\"}"}}
{"type":"tool/result","data":{"turn":3,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_list"},"toolCallId":"call_list","content":[{"type":"text","text":"{{session:2}} [inactive] parent={{session:1}} depth=1 — Reply with CHILD_OK"}],"isError":false,"id":"{{message:11}}"}},"sourceEventSeqs":[36],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":3,"step":1}}
{"type":"step/start","data":{"turn":3,"step":2}}
{"type":"assistant/message","data":{"turn":3,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:12}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1788880389684,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880389684,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788880389684,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788880389684,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1788880389684,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":3,"step":2}}
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}}