Clarify durable subagent catalog and list_agents behavior

This commit is contained in:
Dudu-0223
2026-07-23 10:16:15 +08:00
committed by Tianyi Cui
parent fc59b63c8c
commit ceaef2c3d0
3 changed files with 58 additions and 32 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-durable-subagent-catalog-and-list-agents.md: 8ffc83e26121d7d1b542e549235290c226739a94
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 5011fc05537ef5e8a3310c5b191226e46c98f40d
2026-07-22-durable-subagent-catalog-and-list-agents.md: 47c99ee6171bbb64416eeb497146a8aa11ea6869
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e80e35eddefc050fa49c26cef88df56520eb58f2

View File

@@ -16,29 +16,31 @@ Treat parent-to-child enumeration and `list_agents` as one separately reviewed f
- find materialized session headers whose `parentSession` identifies the caller's session;
- load and validate each candidate's `subagent/descriptor` event without activating the child;
- exclude sessions that are one-shot, corrupt, unsupported, missing, or not direct children;
- overlay the process-local Task association without treating it as durable state.
- union those durable candidates with the parent's process-local Task associations, including active children that have not materialized yet;
- omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic;
- expose an inactive child as resumable only when its descriptor is valid and its provider is currently registered with `resume?()`;
- return every resulting child in stable `createdAt` ascending, child-id ascending order.
Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
### Enumeration decision
The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and calls `load()` only for those direct-child candidates to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports it as unavailable, while persistence listing omits it. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend.
The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and unions those ids with Task associations owned by the parent. An associated child is resolved from the live association and is never passed to `SessionPersistence.load()`; only inactive direct-child candidates are loaded to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports an inactive instance as unavailable, but an active association still appears in `list_agents` as `running`. Once that Task becomes terminal, the child remains discoverable only if its durable descriptor validates. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend.
This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible.
Listing adds no session event and no surface node. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically.
`SessionPersistence.load()` may durably repair an interrupted child log by appending synthetic closing events. The first version accepts this existing persistence side effect: `listChildren()` creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically.
### `list_agents` contract
`SubagentControlService.listChildren(parent)` returns only durable direct children that carry a valid continuable descriptor, then overlays the process-local Task association. The model-facing `list_agents` tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` and reports two operational states:
`SubagentControlService.listChildren(parent)` returns all direct continuable children in the union of durable candidates and active Task associations, plus non-fatal diagnostics for inactive candidates it could not load, validate, or resume. An association records its creation time when the control service allocates the child id; a materialized child uses `SessionHeader.createdAt`. Children are sorted by that `createdAt` ascending, then child id ascending. Diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`; it renders the complete sorted children and diagnostics together, and reports two operational child states:
- `running`: a non-terminal Task-backed activation exists, including startup and settlement before Task terminal publication;
- `resumable`: a valid durable descriptor exists and no activation is associated.
- `running`: a non-terminal Task-backed activation exists, including startup before materialization and settlement before Task terminal publication;
- `resumable`: no activation is associated, a valid durable descriptor exists, and the named provider is currently registered with `resume?()`.
These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Corrupt, unsupported-version, wrong-parent, or missing-child descriptors fail explicitly rather than being silently advertised as resumable.
These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Inactive candidates use three diagnostic reasons: `corrupt` for malformed committed data or descriptor content, `unsupported` for an unknown descriptor version, and `unavailable` when the candidate disappears, another child-specific load fails, or its provider is absent or lacks `resume?()`. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Failure of the initial `SessionPersistence.list()` operation fails the whole call because no candidate set exists. Headers whose `parentSession` names another parent are filtered before descriptor loading and produce no diagnostic.
The first version is read-only and has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state.
The first version has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state.
## Alternatives considered
@@ -50,17 +52,28 @@ The first version is read-only and has no child deletion operation. If later pro
**Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume.
**Fail the whole listing when one child cannot be loaded.** This makes corruption impossible to overlook, but one damaged sibling removes visibility into every healthy child. Per-child diagnostics preserve discovery while keeping each omission explicit.
**Add a repair-free descriptor inspection API.** This would make discovery strictly storage-read-only, but expands the persistence seam solely to avoid the interrupted-tail repair that normal session load and eventual resume already require. The first version accepts `load()` semantics and documents the side effect.
**Paginate or cap the model-facing result.** This bounds one tool result, but makes discovery stateful and can hide older children unless the model follows a cursor. The first version has no arguments and returns the complete stably ordered set; deployments with many durable children accept the corresponding context cost.
## Acceptance criteria
- Enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only children whose persisted descriptor satisfies the durable child-handle contract.
- Listing loads no Agent, appends no session event, and returns the same children from compacted and uncompacted logs.
- `list_agents` returns only valid direct continuable children and reports `running` or `resumable`, with no pass-through runtime status.
- Durable enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only inactive children whose persisted descriptor satisfies the durable child-handle contract; the final result unions those children with parent-owned active associations.
- Listing loads no Agent and appends no catalog or descriptor event itself, but may trigger `SessionPersistence.load()` interrupted-tail repair for inactive children; an already-associated child is never loaded, and compacted and uncompacted logs return the same children.
- `list_agents` takes no arguments and returns all valid direct continuable children plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending.
- Active Task associations appear as `running` even before durable materialization; after Task terminal, the child appears as `resumable` only when its descriptor validates and its currently registered provider implements `resume?()`.
- `list_agents` reports no pass-through runtime status, uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons, and never exposes descriptor contents in a diagnostic.
- Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks.
- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, wrong-parent child, and stale derived-index entry are never advertised as resumable.
- Keyless tests cover fresh and compacted discovery, restart, wrong-parent access, unsupported descriptors, scan behavior, and stale-index fallback. The model-facing tool has runnable snapshot coverage.
- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, and stale derived-index entry are never advertised as resumable; non-child headers are filtered before load.
- A corrupt, unsupported, disappeared, or unloadable candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic, while failure of the initial persistence listing fails the whole call.
- Keyless tests cover fresh and compacted discovery, active unmaterialized children, transition from running association to durable resume, provider absence, stable ordering, restart, parent-header prefiltering, isolated child diagnostics, load repair, scan behavior, and stale-index fallback. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage.
## Risks
- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, corruption, and fallback behavior.
- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior.
- Listing may repair interrupted child logs and persist synthetic closing events even though it creates no Agent. This is the existing `SessionPersistence.load()` contract, not a hidden catalog write.
- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks.
- The no-argument tool returns every direct continuable child and diagnostic. Stable ordering makes the result deterministic but does not bound model-context growth; pagination or deletion remains a later product decision.
- Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease.

View File

@@ -16,29 +16,31 @@ Status: proposed
- 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header
- 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child
- 排除一次性、损坏、不受支持、缺失或并非直接 child 的会话
- 叠加进程内 Task 关联,但不将该关联视为持久化状态。
- 将这些持久化候选与 parent 的进程内 Task 关联合并,包括尚未实际落盘的活跃 child
- 排除一次性 child 且不产生 diagnostic如果候选在枚举后变得不可用或其描述符损坏或版本不受支持则排除该候选并产生对应 child 的 diagnostic
- 仅当非活跃 child 的描述符有效,且其提供方当前已注册并实现 `resume?()` 时,才将它对外标记为 `resumable`
-`createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
### 枚举决策
第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header`SessionHeader.parentSession` 过滤,并且只对这些直接 child 候选调用 `load()` 来归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告该 id 不可用,持久化列表则不会列出它。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。
第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header`SessionHeader.parentSession` 过滤,再将这些 id 与 parent 拥有的 Task 关联合并。已关联的 child 直接从存活关联中解析,绝不会传给 `SessionPersistence.load()`;只有非活跃的直接 child 候选才会被加载以归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告非活跃实例不可用,但活跃关联仍会在 `list_agents` 中显示为 `running`。该 Task 进入终态后,只有在持久化描述符通过校验时,这个 child 才会继续可被发现。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。
这条 O直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。
列表查询不添加会话事件或 surface 节点。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。
`SessionPersistence.load()` 可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这项现有的持久化副作用:`listChildren()` 不会创建 Agent也不会自行追加目录或描述符事件但它并非严格的存储只读操作。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。
### `list_agents` 契约
`SubagentControlService.listChildren(parent)` 返回具有有效可继续描述符的持久化直接 child再叠加进程内 Task 关联。面向模型的 `list_agents` 工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,并报告两种操作状态:
`SubagentControlService.listChildren(parent)` 返回持久化候选与活跃 Task 关联并集中的所有直接可继续 child以及无法加载、校验或恢复非活跃候选时产生的非致命 diagnostic。控制服务分配 child id 时,关联会记录其创建时间;已实际落盘的 child 则使用 `SessionHeader.createdAt`。这些 child 先按该 `createdAt` 升序、再按 child id 升序排序diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它`@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器;它会一并渲染完整的已排序 child 和 diagnostic并报告两种 child 操作状态:
- `running`:存在由非终态 Task 支撑的激活,包括启动阶段和 Task 终态发布前的结算阶段;
- `resumable`:存在有效的持久化描述符,且没有关联任何激活
- `running`:存在由非终态 Task 支撑的激活,包括实际落盘前的启动阶段和 Task 终态发布前的结算阶段;
- `resumable`没有关联任何激活,存在有效的持久化描述符,且其指定的提供方当前已注册并实现 `resume?()`
这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。描述符损坏、版本不受支持、parent 不匹配或 child 缺失时,系统会明确失败,而不会将其静默标记为可恢复
这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。非活跃候选使用三种固定的 diagnostic 原因:格式错误的已提交数据或描述符内容使用 `corrupt`,未知描述符版本使用 `unsupported`,候选消失、出现其他逐 child 加载失败、其提供方缺失或未实现 `resume?()` 时使用 `unavailable`。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。如果初始 `SessionPersistence.list()` 操作失败,因为系统无法获得候选集,整次调用都会失败。`parentSession` 指向其他 parent 的 header 会在加载描述符前被过滤,且不产生 diagnostic
第一版只读,不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。
第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。
## 已考虑的替代方案
@@ -50,17 +52,28 @@ Status: proposed
**持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。
**某个 child 无法加载时让整次列表查询失败。** 这种做法不会让损坏问题被忽略,但一个损坏的 sibling 会让每个健康 child 都不再可见。逐 child diagnostic 在保持每次排除明确可见的同时,也保留了发现能力。
**添加不会触发修复的描述符检查 API。** 这能使发现严格保持存储只读,但仅为避免中断尾部修复就扩展持久化 seam而普通会话加载和最终恢复原本就需要执行该修复。第一版接受 `load()` 的语义,并记录这项副作用。
**对面向模型的结果分页或设置上限。** 这可以限制一次工具结果的大小,但会使发现成为有状态操作,而且除非模型继续跟随 cursor否则可能隐藏更早的 child。第一版不接受参数并返回经稳定排序的完整集合拥有大量持久化 child 的部署需要接受相应的上下文成本。
## 验收标准
- 枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的 child。
- 列表查询不加载 Agent、不追加会话事件,并从经过压缩和未经压缩的日志返回相同的 child。
- `list_agents` 只返回有效的直接可继续 child,并报告 `running` `resumable`,不直接透传运行时状态
- 持久化枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的非活跃 child;最终结果会将这些 child 与 parent 拥有的活跃关联合并
- 列表查询不加载 Agent,也不会自行追加目录或描述符事件,但可能对非活跃 child 触发 `SessionPersistence.load()` 的中断尾部修复;已关联的 child 绝不会被加载,且经过压缩和未经压缩的日志返回相同的 child。
- `list_agents` 不接受参数,返回所有有效的直接可继续 child 及逐 child diagnostic并按 `createdAt` 升序、child id 升序排序
- 活跃 Task 关联即使尚未实际落盘,也会显示为 `running`Task 进入终态后,只有在描述符校验通过,且当前注册的提供方实现 `resume?()`child 才会显示为 `resumable`
- `list_agents` 不直接透传运行时状态,只使用 `corrupt``unsupported``unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。
- 恢复 parent 不会激活 child列表查询读取持久化状态并且只叠加已经关联的进程内 Task。
- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本、parent 不匹配的 child 和陈旧的派生索引条目绝不会被标记为可恢复。
- 无密钥测试覆盖压缩前后的发现、重启、错误 parent 访问、不受支持的描述符、扫描行为和陈旧索引回退。面向模型的工具具有可运行的快照覆盖
- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本和陈旧的派生索引条目绝不会被标记为可恢复;非 child header 会在加载前被过滤
- 损坏、不受支持、已消失或无法加载的候选不能隐藏健康的 sibling系统会排除该候选并生成一条含 id 和原因的 diagnostic只有初始持久化列表查询失败时整次调用才会失败
- 无密钥测试覆盖压缩前后的发现、活跃的尚未实际落盘 child、从正在运行的关联转换为持久化恢复、提供方缺失、稳定排序、重启、parent header 预过滤、单个 child diagnostic 隔离、加载修复、扫描行为和陈旧索引回退。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。
## 风险
- 列表查询会扫描一次 header并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、损坏处理和回退行为。
- 列表查询会扫描一次 header并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。
- 列表查询可能修复中断的 child 日志并持久化合成的结束事件,即使它不创建 Agent。这是 `SessionPersistence.load()` 的现有契约,而非隐藏的目录写入。
- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。
- 无参数工具会返回每个直接可继续 child 和 diagnostic。稳定排序可使结果确定但不会限制模型上下文的增长分页或删除仍是后续的产品决策。
- Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`