DSH / Atlas
2026-07-22implementedfeature

Durable subagent catalog and list_agents

持久化 subagent 目录与 list_agents

Continuable background subagents expose a stable child id and persist reconstruction data in that child's session, so `send_message` can resume a known child without any listing operation. Discovery has two consumers with different needs: a UI may show both one-shot work and continuable conversations, while the model should receive only children on which `send_message` is meaningful. The durable Session and Activatio

Source compatibility: Chinese title uses a compatible non-canonical header

English

Problem

Continuable background subagents expose a stable child id and persist reconstruction data in that child's session, so send_message can resume a known child without any listing operation. Discovery has two consumers with different needs: a UI may show both one-shot work and continuable conversations, while the model should receive only children on which send_message is meaningful. The durable Session and Activation design is owned by continuable subagents; this note owns the shared durable inventory and the model-facing projection.

Enumeration must cross-check immutable session lineage, descriptor validity, and the live-preferred session corpus without loading or resuming an Agent merely to display it. Trace lineage supplies candidates but cannot distinguish an ordinary session fork from a subagent, so the child log needs durable classification. The contract must also define how lifecycle mode, missing or corrupt records, deletion, unsupported versions, and repeated child-log loads affect service and tool consumers.

Decision

Superseded read path. Subagent list identity via the projection unit replaces this note's enumeration and per-child read design: listChildren now merges the live session store with optional session persistence directly and serves each child's mode/label from the registered subagent projection unit — no session-query dependency, no list-time descriptor scan — and that note owns the current listing semantics, including the diagnostic mapping. This note remains the authority for descriptor persistence, the mode-discriminated descriptor as durable identity, direct-parent authorization, and the model-facing list_agents projection; the trace-based read mechanics below are decision context, not current behavior.

Parent-to-child enumeration is a service capability with consumer-specific projections. SubagentRuntime.listChildren(parentSessionId: SessionId) (subagent/src/index.ts) does the following:

  • use ctx.sessionQuery.traceSession(parentSessionId) to obtain the parent's direct live-preferred child sessions;
  • read and validate each candidate's subagent/descriptor event without activating the child;
  • silently omit candidates without a descriptor, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic;
  • expose every local session-backed subagent whose supported valid descriptor carries a durable creation label and mode, regardless of whether its provider is currently registered;
  • report corpus activity separately as running or inactive, without implying completion or resumability;
  • return every resulting child in stable createdAt ascending, child-id ascending order.

Every ordinary local start receives a one-shot descriptor with an optional caller-owned display label, while the continuation manager persists a labeled continuable descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short description and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing list_agents adapter filters the service result to continuable children and refines status through the live Agent registry (running/idle, and ready for storage-only); a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.

Enumeration decision

The first implementation consumes ctx.sessionQuery.traceSession(parentSessionId) and considers only the trace's first-level descendants. The target may be live or persisted; tracing the logical corpus does not load or resume an Agent. Session query already merges ctx.sessions with ctx.sessionPersistence using live precedence, preserves immutable-header consistency, derives direct-child lineage from SessionHeader.parentSession, and sorts siblings by createdAt ascending and child id ascending. listChildren() does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map.

Corpus construction precedes per-child descriptor inspection. A failure while building the initial trace, including persistence listing failure, a live/persisted header conflict anywhere in the observed corpus, or invalid target lineage, fails the whole list_agents call because no trustworthy candidate set exists. Only failures after a successful trace are isolated to one candidate; "corrupt child" in that per-child contract therefore means corrupt loaded event surface or descriptor data, not a corpus-level header conflict.

Session lineage is broader than subagent identity: an ordinary ctx.sessions.fork() also creates a direct child. The session header gains no kind discriminator; each candidate must instead contain exactly one valid subagent/descriptor event in its own suffix. SubagentRuntime.start() resolves { mode: 'one-shot', provider, label? } before ordinary provider dispatch, while the continuation manager snapshots and seeds { mode: 'continuable', ...composition } during initial child creation. The local in-process one-shot driver appends its resolved descriptor only during initial creation, and cold resume appends no further descriptor; a second event is corruption rather than evidence of another Activation. Agent creation is the one-shot publication boundary: a rejection means no child was published, while prompt, turn, cancellation, and infrastructure outcomes after publication settle through the returned run without hiding its id. The descriptor event is the sole evidence that a traced child is a session-backed subagent. A candidate without it is an ordinary fork, a remote child without a local session record, or another non-subagent session and is omitted without a diagnostic.

The published logical record is also the activity source: SessionRecord.live means running, while live: false, persisted: true means inactive. Activity comes directly from the trace and causes no additional child-log load. inactive encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which send_message can materialize another Activation. Conversely, running says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as running, but send_message rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; send_message remains the authoritative delivery-time operation.

The subagent service keeps sessionQuery optional so start and follow-up remain available without it. Its public listChildren(parentSessionId: SessionId) method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to SubagentRuntime: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, subagents, and sessionQuery can list even when agents is absent. The method throws SubagentError with stable code SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE before loading the runtime or doing query work when the query service is absent. @deepseek-ai/dsh-tool-subagent-control exports separately loadable tool plugins: the send_message adapter requires only subagents, while the list_agents adapter requires both subagents and sessionQuery at load. A deployment may therefore use send_message without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional sessionQuery, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE, SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE) and the narrowed load requirement live in the superseding note.

listChildren(parentSessionId, signal?) forwards the caller's signal to traceSession() and the conditional exact readEvent() operation. listEvents() has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to SubagentError with stable code CANCELLED; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing.

This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C be the number of persisted sessions scanned by each persistence listing, and L_i be the size of candidate i's full log. One corpus trace is followed by sessionQuery.listEvents(childId) for every candidate. A candidate with no descriptor is omitted, and one with multiple descriptors is diagnosed without another read; only a candidate with exactly one descriptor is loaded again through sessionQuery.readEvent({ sessionId: childId, seq }). The read must return the same immutable session header observed by the trace, including the direct-parent relationship, and its target must still be the located descriptor event; a mismatch is per-child corruption. In the persisted-only worst case, each exact read repeats persistence.list(), loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a candidate with exactly one descriptor pays those costs twice, while other candidates pay them once. A live candidate similarly takes one detached in-memory snapshot of its full log, or two when its descriptor is read. Session query resolves persisted candidates through the persistence seam's non-mutating inspect() read, which returns the valid stored prefix without repairing a torn tail or closing an interrupted turn, so listing is storage-read-only; repair remains the resume path's concern. The first version accepts these repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog, descriptor, or repair event. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically.

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 unpublished child visible.

list_agents contract

SubagentRuntime.listChildren(parentSessionId: SessionId) returns Promise<SubagentListEntry[]>, one array preserving the trace's candidate order rather than separate child and diagnostic arrays. SubagentListEntry is a closed union discriminated by its readonly kind:

  • kind: 'child' carries readonly id: SessionId, mode: 'one-shot' | 'continuable', and activity: 'running' | 'inactive'; a continuable child carries label: string, while a one-shot child carries label?: string;
  • kind: 'diagnostic' carries readonly id: SessionId and reason: 'corrupt' | 'unsupported' | 'unavailable'.

A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. mode is durable creation policy; activity is a process-local corpus snapshot. Activity is neither AgentStatus, the manager's internal Activation state, nor a durable outcome, and the result does not expose the internal createdAt sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature.

The model-facing list_agents tool takes one optional scope: 'children' | 'descendants' argument, derives the root id from the current execution Agent, and resolves the request through an explicit request-to-spec step (undefinedchildren) before either execution or rendering. The resolved children scope calls SubagentRuntime.listChildren(rootSessionId), while descendants calls SubagentRuntime.listDescendants(rootSessionId). Its internal output projection keeps id and parent as branded SessionId values until the tool JSON boundary. It keeps diagnostics, drops one-shot child entries, derives status from the live Agent registry — running for an active driver, idle for a resident Agent between turns, and ready when no live Agent remains — resumable rather than terminal — then renders <id> [<status>] — <label> or <id> [diagnostic: <reason>] in stable catalog order. The descendants scope flattens the complete tree from one live-preferred corpus in stable pre-order, traverses ordinary and one-shot intermediates so deeper continuable agents are discovered, revalidates each cold candidate against its enumerated lifecycle, and adds parentId/depth to every entry. The tool inserts parent=<id> depth=<n> before the label; parent is the durable direct-parent session id and may name an omitted ordinary session. For the current caller, only depth-1 child rows are send_message candidates, while deeper child rows may be selected for interrupt_agent (interrupt contract). Discovery is a hint only — follow-up authority stays exact-direct-parent, and interrupt authority stays with the service's live-lineage check. An empty projection renders (no subagents).

In the superseded trace-based path, diagnostics used three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, a read result whose immutable header differs from the traced candidate or no longer names the requested direct parent, a target that is no longer the located descriptor event, malformed descriptor content, and multiple descriptor events mapped to corrupt. An unknown descriptor version mapped to unsupported. SESSION_QUERY_SESSION_NOT_FOUND, SESSION_QUERY_EVENT_NOT_FOUND, and SESSION_QUERY_PERSISTENCE_FAILED from a per-child read mapped to unavailable. This phase boundary was intentional: a persistence outage during the initial trace failed the operation, while the same outage beginning during candidate reads could produce one identical unavailable diagnostic per affected child; the first version neither coalesced those diagnostics nor promoted them to a global failure. A missing descriptor was instead a non-subagent exclusion without a diagnostic. Configuration/window errors and unrecognized failures were not child diagnostics and propagated as operation failures. Each diagnostic identified the child id and reason without exposing model-hidden descriptor content; the candidate was omitted while healthy siblings remained visible. Sessions outside the trace's direct descendants were never read and produced no diagnostic.

Diagnostics are transient query results, not session events or catalog state. Deriving a diagnostic performs no additional load beyond the listEvents() or conditional readEvent() operation whose result produced it.

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

Fold listing into the activation RFC. Descriptor-by-id persistence and cold resume do not require parent-to-child enumeration. Keeping the query separate lets send_message land without taking on listing states, scanning performance, or deletion behavior.

Rebuild lineage directly from SessionPersistence.list(). This duplicates session query's live-preferred corpus merge, immutable-header consistency checks, direct-child tracing, and deterministic ordering. Listing uses the existing trusted query service and adds only subagent-specific descriptor validation and rendering.

List every traced child session. parentSession proves lineage but does not prove that the child is a subagent: ordinary session forks share that header field. Listing must also read and validate the descriptor.

Add a kind discriminator to SessionHeader. The header would still not carry the reconstruction data required to validate or resume a continuable subagent, so listing would have to read the descriptor anyway. Treating the descriptor as the sole subagent discriminator avoids a second classification source.

Use the live Agent registry as the catalog. Activations are deliberately disposed after settlement, and registry state disappears on restart. It cannot support durable discovery.

Use the process-local Activation map as a second catalog. This exposes manager residency but couples a session-discovery query to materialization and settlement, introduces another ordering clock, and makes the same child change candidate source during its lifetime. The first version lists published logical sessions only and treats SessionRecord.live as its snapshot status.

Route listing through the Activation-based continuation manager. The manager owns residency and requires agents, while listing interprets only session-query facts. Routing the read through that manager would impose an unrelated runtime service and make discovery disappear with Activation control, so SubagentRuntime owns listing directly.

Filter by current provider availability. Provider registration is process-local and may change while the descriptor remains durable. Filtering can hide both a persisted child and a live child even though continuation is provider-independent. Listing therefore establishes durable identity from the descriptor, while send_message performs the authoritative delivery-time authority and residency checks.

Persist a parent-session catalog event. Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume.

Require a display label on every raw start. This guarantees uniform UI text but makes a presentation concern part of workflow, transport, test, and programmatic start contracts. The raw request keeps the label optional; high-level delegation and continuation APIs supply one where they already own the concept, and UI consumers choose a fallback for unlabeled one-shot children.

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.

Return separate child and diagnostic arrays. Separate arrays introduce two ordering domains or require exposing another sort key to reconstruct candidate order. One discriminated entry array preserves the trace order while keeping child and diagnostic fields type-safe.

Read candidates through the repairing load() path. Reusing resume's load() semantics would let discovery durably close an interrupted tail early, but turns a listing query into a mutating operation and couples its failure modes to write coordination. Session query's corpus reads already use the non-mutating inspect() contract, so listing stays storage-read-only and leaves tail repair to the resume path that needs it.

Paginate or cap the query now (deferred). This bounds one result, but makes model discovery stateful and can hide older children unless the model follows a cursor. The first version has no cursor, page arguments, or candidate-limit configuration and returns the complete stably ordered set; a service-level bound remains a later decision if measured scale requires it.

Testing

  • packages/subagent/subagent/tests/service.spec.ts pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
  • packages/subagent/subagent/tests/list-children.spec.ts pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE and SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as corrupt; a failed cold inspection as one unavailable diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as corrupt on both the live and cold paths; createdAt-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable CANCELLED; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
  • packages/subagent/tool-subagent-control/tests/list-agents.spec.ts pins the list_agents schema (one optional scope enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the agents load requirement without sessionQuery, and HMR disposal.
  • The keyless ACP snapshot scenario subagent-list-agents (examples/acp-agent) fences its second parent turn on a snapshot-only subagent/end marker, then executes list_agents for real against the subagent service, the projection registry, and JSONL persistence, rendering <id> [ready] — <label>.
  • The keyless snapshot scenario subagent-diagnostic (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a corrupt diagnostic.
  • The keyless ACP snapshot scenario subagent-published-run-failure publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result.

Consequences

  • Session tracing observes the complete logical corpus, then descriptor validation reads every direct-child log once and candidates with exactly one descriptor twice. In the persisted-only worst case, work is O(D × C + Σ L_i), not merely O(D), because each exact read rescans persistence and loads and clones the full candidate log. A later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior.
  • Corpus construction is an all-or-nothing trust boundary: one live/persisted header conflict fails the initial trace and hides otherwise healthy siblings. Per-child isolation begins only after that trace succeeds.
  • A torn child tail is surfaced, not repaired: the non-mutating inspect() read returns the valid stored prefix, so a child interrupted mid-write may list from a shorter log until the resume path's repairing load closes it.
  • There is 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 resident Activations.
  • The service returns every direct session-backed subagent and diagnostic without a cursor or candidate cap. Stable ordering makes the result deterministic; the model projection avoids one-shot context growth but remains unbounded in the number of continuable children.
  • running and inactive are process-local corpus snapshots, not outcomes or delivery promises. Another process may activate a persisted child while this process reports it as inactive; cross-process accuracy requires a shared lease.
  • Persisting lifecycle mode is a pre-release descriptor-format change: version 2 rejects old version-1 descriptors as unsupported. Every local session-backed start now pays one small log event so UI and other service consumers can classify one-shot history without replaying model-visible tool results.
  • One-shot session persistence remains best-effort. A one-shot child is visible while live and remains discoverable after disposal only when its session checkpoint reached persistence; unlike continuable activation, catalog participation does not add a required final flush or turn a persistence failure into a run failure. Continuable starts need no eager descriptor flush because the descriptor rides in the creation seed and every Activation disposal path performs the required final checkpoint, including after blocked prompt admission.
  • Remote ACP one-shot runs remain outside the catalog because they publish no local child session for traceSession() to discover. Enumerating them would require a separate durable local record rather than pretending remote lifecycle ids are session ids.

中文

问题

可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 send_message 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 send_message 的 child。可继续 subagent负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。

枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。追踪谱系可以提供候选项,却无法区分普通会话 fork 与 subagent,因此 child 日志需要持久化分类。约定还必须定义生命周期模式、缺失或损坏的记录、删除、不受支持的版本,以及反复加载大量 child 日志会如何影响服务与工具消费方。

决策

列表读路径已被取代。subagent 列表经投影单元读取身份取代了本记录的枚举与逐 child 读取设计:listChildren 现在直接合并存活会话存储与可选的会话持久化,并从注册的 subagent projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 list_agents 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。

parent 到 child 的枚举是一项带消费方专用投影的服务功能。SubagentRuntime.listChildren(parentSessionId: SessionId)subagent/src/index.ts)执行以下操作:

  • 使用 ctx.sessionQuery.traceSession(parentSessionId) 获取 parent 的直接且实时优先的 child 会话;
  • 读取并校验每个候选会话的 subagent/descriptor 事件,但不激活 child;
  • 默默排除不含描述符的候选;如果候选变得不可用,或其描述符损坏或版本不受支持,则排除该候选并产生对应 child 的 diagnostic;
  • 公开每个由本地会话支撑、拥有受支持且有效描述符的 subagent;该描述符必须带有持久化的创建 labelmode,而其提供方当前是否已注册不影响公开;
  • 将语料活动状态单独报告为 runninginactive,但不暗示已完成或可恢复;
  • createdAt 升序、再按 child id 升序稳定返回所有结果 child。

每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 one-shot 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 continuable 描述符。面向模型的委派工具已经拥有简短 description,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 list_agents 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(runningidle,以及对应仅存于存储的 ready);UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。

枚举决策

第一版消费 ctx.sessionQuery.traceSession(parentSessionId),并且只考虑追踪结果的第一层后代。目标可以存活,也可以只存在于持久化存储中;追踪逻辑语料不会加载或恢复 Agent。会话查询已经使用实时优先规则合并 ctx.sessionsctx.sessionPersistence,保持不可变 header 一致性,根据 SessionHeader.parentSession 推导直接 child 谱系,并按 createdAt 升序、child id 升序排列 sibling。listChildren() 不会重复实现这套语料逻辑,也不会检查继续执行管理器的进程内 Activation map。

语料构建先于逐 child 描述符检查。构建初始追踪时如果发生持久化列表查询失败、所观测语料中任意位置的存活/持久化 header 冲突或目标谱系无效,整个 list_agents 调用都会失败,因为此时不存在可信的候选集。只有初始追踪成功后的失败才会被隔离到单个候选;因此,这项逐 child 约定中的“损坏 child”是指已加载的事件 surface 或描述符数据损坏,而不是语料级 header 冲突。

会话谱系涵盖的范围比 subagent 身份更广:普通 ctx.sessions.fork() 也会创建直接 child。会话 header 不新增 kind 判别字段;每个候选必须改为在自身后缀中恰好包含一个有效的 subagent/descriptor 事件。SubagentRuntime.start() 会在普通提供方分发前解析 { mode: 'one-shot', provider, label? },而继续执行管理器会在初始创建 child 时为 { mode: 'continuable', ...composition } 建立快照并将其作为 seed。本地进程内的一次性驱动只在初始创建期间追加已解析的描述符,从持久化存储冷恢复时不会追加其他描述符;第二个事件属于损坏,而不是另一次 Activation 的证据。Agent 创建是一次性运行的发布边界:拒绝表示没有发布 child,而发布后的提示词、轮次、取消与基础设施结果会通过返回的 run 结算,且不会隐藏其 id。描述符事件是已追踪 child 属于由会话支撑的 subagent 的唯一证据。缺少该事件的候选属于普通 fork、没有本地会话记录的远端 child 或其他非 subagent 会话,系统会将其排除且不产生 diagnostic。

已发布的逻辑记录同时也是活动状态来源:SessionRecord.live 表示 running,而 live: false, persisted: true 表示 inactive。活动状态直接来自追踪结果,不会导致额外加载 child 日志。inactive 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 send_message 可以为其物化另一次 Activation 的可继续 child。反过来,running 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 running,但 send_message 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;send_message 仍是消息送达时的权威操作。

subagent 服务将 sessionQuery 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 listChildren(parentSessionId: SessionId) 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 SubagentRuntime 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、subagentssessionQuery 的部署即使缺少 agents 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 SubagentError,并携带稳定错误码 SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE@deepseek-ai/dsh-tool-subagent-control 导出可分别加载的工具插件:send_message 适配器只要求 subagents,而 list_agents 适配器在加载时同时要求 subagentssessionQuery。因此,部署可以在既不安装也不加载会话查询的情况下使用 send_message;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 sessionQuery、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLESUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE)与收窄后的加载要求以取代记录为准。

listChildren(parentSessionId, signal?) 会把调用方的取消信号转发给 traceSession() 和条件性精确 readEvent() 操作。listEvents() 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 SubagentError,并携带稳定错误码 CANCELLED;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。

这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选都会执行 sessionQuery.listEvents(childId)。没有描述符的候选会被排除;含有多个描述符的候选会直接产生 diagnostic,无需再次读取;只有恰好含有一个描述符的候选才会通过 sessionQuery.readEvent({ sessionId: childId, seq }) 再次加载。此次读取返回的不可变会话 header 必须与追踪时观测到的相同,包括直接 parent 关系,并且读取目标仍必须是先前定位的描述符事件;任何不一致均视为该 child 损坏。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 persistence.list()、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);恰好含有一个描述符的候选承担两次这类成本,其他候选只承担一次。存活候选同样会对其完整日志取得一份分离的内存快照;读取其描述符时则会取得两份。会话查询通过持久化 seam 的非变更 inspect() 读取解析持久化候选:它返回有效的已存储前缀,既不修复撕裂的尾部,也不关闭中断的 turn,因此列表查询是存储只读操作;修复仍是恢复路径的职责。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会追加任何目录、描述符或修复事件。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。

如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未发布的 child 变得可见。

list_agents 约定

SubagentRuntime.listChildren(parentSessionId: SessionId) 返回 Promise<SubagentListEntry[]>,其中的单个数组不会将 child 与 diagnostic 分开,而是保留追踪结果中的候选顺序。SubagentListEntry 是一个由只读 kind 判别的封闭联合类型:

  • kind: 'child' 携带只读的 id: SessionIdmode: 'one-shot' | 'continuable'activity: 'running' | 'inactive';可继续 child 携带 label: string,一次性 child 则携带 label?: string
  • kind: 'diagnostic' 携带只读的 id: SessionIdreason: 'corrupt' | 'unsupported' | 'unavailable'

有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。mode 是持久化创建策略;activity 是进程本地语料快照。活动状态既不是 AgentStatus、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 createdAt 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。

面向模型的 list_agents 工具接受一个可选的 scope: 'children' | 'descendants' 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(undefinedchildren)。解析后的 children scope 调用 SubagentRuntime.listChildren(rootSessionId)descendants scope 则调用 SubagentRuntime.listDescendants(rootSessionId)。其内部输出投影中的 idparent 会一直保持为品牌化的 SessionId 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 one-shot child 条目,状态取自在线 Agent 注册表——driver 活跃为 running,驻留但处于轮次之间为 idle,没有在线 Agent 时为 ready(可恢复而非终态)——然后按稳定目录顺序渲染 <id> [<status>] — <label><id> [diagnostic: <reason>]descendants scope 从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent,依据枚举生命周期重新校验每个冷候选,并为每个条目附加 parentIddepth。工具会在 label 之前插入 parent=<id> depth=<n>parent 是持久化直接 parent 会话 id,可能指向被省略的普通会话。对于当前调用方,只有 depth-1 child 条目可作为 send_message 候选,更深的 child 条目则可供 interrupt_agent 选择(中断约定)。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent,中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 (no subagents)

在已被取代的追踪读路径中,diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 corrupt。未知描述符版本映射为 unsupported。逐 child 读取产生的 SESSION_QUERY_SESSION_NOT_FOUNDSESSION_QUERY_EVENT_NOT_FOUNDSESSION_QUERY_PERSISTENCE_FAILED 映射为 unavailable。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 unavailable diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。

diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导 diagnostic 时,除了产生该结果的 listEvents() 或条件性 readEvent() 操作外,不会执行额外加载。

第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 list_agents 保留陈旧状态。

已考虑的替代方案

将列表查询并入激活 RFC。 按 id 持久化描述符和从持久化存储恢复无需 parent 到 child 的枚举。保持查询独立,可让 send_message 落地时不必同时承担列表状态、扫描性能或删除行为。

直接通过 SessionPersistence.list() 重建谱系。 这种做法会重复实现会话查询中的实时优先语料合并、不可变 header 一致性检查、直接 child 追踪和确定性排序。列表查询应使用现有可信查询服务,只增加 subagent 特有的描述符校验与渲染。

列出每个已追踪的 child 会话。 parentSession 能证明谱系,却不能证明 child 是 subagent:普通会话 fork 也使用这个 header 字段。列表查询还必须读取并校验描述符。

SessionHeader 添加 kind 判别字段。 header 仍不会携带校验或恢复可继续 subagent 所需的重建数据,因此列表查询无论如何都必须读取描述符。将描述符作为唯一的 subagent 判别信息,可避免引入第二个分类来源。

使用存活的 Agent 注册表作为目录。 系统会在 Activation 结算后有意 dispose 它,而且注册表状态会在重启时消失,因此无法支持持久化发现。

使用进程内 Activation map 作为第二个目录。 这种做法能公开管理器驻留状态,却会让会话发现查询与物化及结算耦合,引入另一套排序时钟,并让同一个 child 在其生命周期内改变候选来源。第一版只列出已经发布的逻辑会话,并将 SessionRecord.live 视为其快照状态。

让列表查询经过基于 Activation 的继续执行管理器。 管理器负责驻留状态并要求 agents,而列表查询只解释会话查询事实。让读取经过该管理器会强制引入无关的运行时服务,并使发现能力随 Activation 控制一同消失,因此列表查询直接由 SubagentRuntime 负责。

按当前提供方可用性过滤。 提供方注册状态属于进程本地状态,即使描述符仍然持久存在,该状态也可能发生变化。即使继续执行不依赖提供方,过滤仍可能隐藏持久化或存活 child。因此,列表查询根据描述符确立持久化身份,而 send_message 在消息送达时执行权威的鉴权与驻留状态检查。

持久化 parent 会话目录事件。 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。

要求每次底层启动都提供显示标签。 这会保证 UI 文本一致,却会把展示关注点引入 workflow、传输、测试和程序化启动约定。底层请求保持标签可选;高层委派与继续执行 API 在本就拥有该概念时提供标签,UI 消费方则为无标签的一次性 child 选择回退展示。

某个 child 无法加载时让整次列表查询失败。 这种做法不会让损坏问题被忽略,但一个损坏的 sibling 会让每个健康 child 都不再可见。逐 child diagnostic 在保持每次排除明确可见的同时,也保留了发现能力。

分别返回 child 和 diagnostic 数组。 分离的数组会引入两个排序域,或者要求公开另一个排序键才能重建候选顺序。一个带判别字段的条目数组既能保留追踪顺序,也能保证 child 与 diagnostic 字段的类型安全。

通过会触发修复的 load() 路径读取候选。 复用恢复路径的 load() 语义可以让发现提前持久化地关闭中断尾部,但会把列表查询变成变更操作,并使其失败模式与写协调耦合。会话查询的语料读取本就使用非变更的 inspect() 约定,因此列表查询保持存储只读,尾部修复留给真正需要它的恢复路径。

立即为查询分页或设置上限(暂缓)。 这可以限制一次结果的大小,但会使模型发现成为有状态操作,而且除非模型继续跟随 cursor,否则可能隐藏更早的 child。第一版没有 cursor、分页参数或候选数量上限配置,而是返回经稳定排序的完整集合;如果实测规模需要限制,服务级限制仍留待后续决策。

测试

  • packages/subagent/subagent/tests/service.spec.ts 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
  • packages/subagent/subagent/tests/list-children.spec.ts 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLESUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 corrupt;冷检查失败成一条 unavailable diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 corrupt;按 createdAt 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 CANCELLED;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
  • packages/subagent/tool-subagent-control/tests/list-agents.spec.ts 固定 list_agents 的 schema(一个可选 scope 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 agents 但不再注入 sessionQuery 的加载约定,以及 HMR dispose。
  • 无密钥 ACP 快照场景 subagent-list-agents(examples/acp-agent)使用仅限快照的 subagent/end 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 list_agents,渲染 <id> [ready] — <label>
  • 无密钥快照场景 subagent-diagnostic(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 corrupt diagnostic 出现。
  • 无密钥 ACP 快照场景 subagent-published-run-failure 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。

影响

  • 会话追踪会观察完整的逻辑语料,随后描述符校验会读取每个直接 child 的日志一次,并对恰好含有一个描述符的候选读取两次。对于只存在于持久化存储中的最坏情况,工作量为 O(D × C + Σ L_i),而不只是 O(D),因为每次精确读取都会重新扫描持久化存储,并加载和克隆候选的完整日志。后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。
  • 语料构建是一个全有或全无的信任边界:一处存活/持久化 header 冲突就会导致初始追踪失败,并隐藏原本健康的 sibling。只有初始追踪成功后,逐 child 隔离才会生效。
  • 撕裂的 child 尾部会被呈现而非修复:非变更的 inspect() 读取返回有效的已存储前缀,因此写入中途被打断的 child 在恢复路径的修复加载将其关闭之前,可能以较短的日志形式出现在列表中。
  • 没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由驻留 Activation 数量限制。
  • 服务会返回每个直接且由会话支撑的 subagent 和 diagnostic,不设 cursor 或候选数量上限。稳定排序可使结果确定;模型投影避免了一次性 child 带来的上下文增长,但可继续 child 的数量仍无上限。
  • runninginactive 是进程本地语料快照,而非结果或消息送达承诺。另一个进程可能在当前进程将某个持久化 child 报告为 inactive 时激活它;跨进程准确性需要共享租约。
  • 持久化生命周期模式是一次发布前的描述符格式变更:版本 2 会将旧版版本 1 描述符拒绝为不受支持。现在,每次由本地会话支撑的启动都会产生一个小型日志事件,使 UI 和其他服务消费方无需重放模型可见的工具结果即可对一次性历史进行分类。
  • 一次性会话持久化仍为尽力执行。一次性 child 在存活期间可见,dispose 后只有在其会话检查点到达持久化存储时才可继续被发现;目录参与不会像可继续激活那样增加必需的最终 flush,也不会把持久化失败变为 run 失败。可继续启动无需提前 flush 描述符,因为描述符会随创建 seed 一同携带,并且每条 Activation dispose 路径都会执行必需的最终检查点,包括提示词准入受阻之后。
  • 远端 ACP 一次性运行仍不在目录中,因为它们不会发布可供 traceSession() 发现的本地 child 会话。若要枚举这些运行,需要单独的持久化本地记录,不能假装远端生命周期 id 就是会话 id。