feat(subagent): durable child catalog and list_agents
Implements the durable-subagent-catalog RFC: SubagentControlService.listChildren() enumerates a parent's direct continuable children from one sessionQuery trace, validates each child's sole subagent/descriptor event (now carrying the durable creation label), and returns one ordered SubagentListEntry[] with per-child corrupt/unsupported/unavailable diagnostics. The list_agents tool ships as a separately loadable plugin of dsh-tool-subagent-control requiring sessionQuery at load; send_message stays usable without it.
This commit is contained in:
55 files changed
+1128
-94
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md
|
||||
2026-07-21-continuable-background-subagents.md: 513ee668a9e04c05bb50f946016c460e09d1ddcd
|
||||
2026-07-21-continuable-background-subagents.zh.md: 88cdd00582b18a2092c6993507fe3d4b92f237ae
|
||||
2026-07-21-continuable-background-subagents.md: b914533e759e46a3f8641ac2c9b49badc2377c5c
|
||||
2026-07-21-continuable-background-subagents.zh.md: 9f3dace04fe6f13e2136a8f3b96cc2ef4363e82d
|
||||
@@ -27,7 +27,7 @@ durable child Session
|
||||
|
||||
Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. Each `tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`; configured continuable mode requires the mounted provider's `resume` capability, while a resumable provider may still use one-shot background policy. ACP children remain one-shot until the deferred ACP continuation work below is complete.
|
||||
|
||||
`ctx.subagents` is the only public service. Ordinary `start` stays collection-, Task-, and persistence-agnostic: it validates provider capabilities, dispatches one activation, observes run lifecycle, and returns a holder-owned run. An injected internal continuation manager owns stable child ids, descriptor persistence and lookup, Task-backed activation, and routing through `startContinuable` and `followup`; provider start and resume dispatch use private closures after the manager resolves continuation state. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call the intent operations for continuable background work; foreground and one-shot background delegation use ordinary `start`. The globally named model tool is a thin optional adapter in `@deepseek-ai/dsh-tool-subagent-control`, and its presence does not decide whether continuable work starts. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal.
|
||||
`ctx.subagents` is the only public service. Ordinary `start` stays collection-, Task-, and persistence-agnostic: it validates provider capabilities, dispatches one activation, observes run lifecycle, and returns a holder-owned run. An injected internal continuation manager owns stable child ids, descriptor persistence and lookup, Task-backed activation, and routing through `startContinuable` and `followup`; provider start and resume dispatch use private closures after the manager resolves continuation state. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call the intent operations for continuable background work; foreground and one-shot background delegation use ordinary `start`. The globally named model tool is a thin optional adapter in `@deepseek-ai/dsh-tool-subagent-control`, and its presence does not decide whether continuable work starts. Parent-to-child enumeration and `list_agents` belong to the [durable subagent catalog](2026-07-22-durable-subagent-catalog-and-list-agents.md).
|
||||
|
||||
### Task and cancellation ownership
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ durable child Session
|
||||
|
||||
前台委派保持一次性行为。继续执行覆盖后台的进程内 spawn 和 fork child。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 功能,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。
|
||||
|
||||
`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Task 与持久化:它校验提供方功能、分发一次激活、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、描述符持久化与查找、由 Task 支撑的激活,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器解析继续执行状态后,提供方的 start 与 resume 分发通过私有闭包进行。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。
|
||||
`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Task 与持久化:它校验提供方功能、分发一次激活、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、描述符持久化与查找、由 Task 支撑的激活,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器解析继续执行状态后,提供方的 start 与 resume 分发通过私有闭包进行。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举与 `list_agents` 属于[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)。
|
||||
|
||||
### Task 与取消的所有权
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: 634cb8708f951dfd62e1345f96420cba0d7baed9
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: a3d282d3de63ca3bc081c071e66c2713422ebcc2
|
||||
+12
-21
@@ -1,6 +1,6 @@
|
||||
# Agent Note: Durable subagent catalog and list_agents
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)
|
||||
|
||||
@@ -10,9 +10,9 @@ Continuable background subagents expose a stable child id and persist the recons
|
||||
|
||||
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. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index.
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parentSessionId: SessionId)` must:
|
||||
Parent-to-child enumeration and `list_agents` are one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parentSessionId: SessionId)` 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;
|
||||
@@ -21,7 +21,7 @@ Treat parent-to-child enumeration and `list_agents` as one separately reviewed f
|
||||
- report a live child as `running` and a persisted-only child as `complete`;
|
||||
- return every resulting child in stable `createdAt` ascending, child-id ascending order.
|
||||
|
||||
Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. This proposal extends the descriptor with a durable `label` and requires listing to diagnose duplicate descriptor events; it cannot weaken the existing facts or invent a second descriptor representation.
|
||||
Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. The catalog extends the descriptor with a durable `label` and requires listing to diagnose duplicate descriptor events; it does not weaken the existing facts or invent a second descriptor representation.
|
||||
|
||||
### Enumeration decision
|
||||
|
||||
@@ -46,7 +46,7 @@ If measured scale later requires an index, that index is derived state: session
|
||||
- `kind: 'child'` carries readonly `id: SessionId`, durable `label: string`, and `status: 'running' | 'complete'`;
|
||||
- `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. Child status `running` means that the logical record is live in `ctx.sessions`; `complete` means that it exists only in persistence. These values are neither `AgentStatus` nor the manager's internal Activation state, 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 proposal.
|
||||
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. Child status `running` means that the logical record is live in `ctx.sessions`; `complete` means that it exists only in persistence. These values are neither `AgentStatus` nor the manager's internal Activation state, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature.
|
||||
|
||||
The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It renders entries in array order as `<id> [<status>] — <label>` for a child and `<id> [diagnostic: <reason>]` for a diagnostic; an empty array renders `(no subagents)`.
|
||||
|
||||
@@ -82,27 +82,18 @@ The first version has no child deletion operation. If later product behavior del
|
||||
|
||||
**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.
|
||||
|
||||
## Acceptance criteria
|
||||
## Testing
|
||||
|
||||
- `listChildren(parentSessionId: SessionId)` uses `ctx.sessionQuery.traceSession(parentSessionId)`, accepts a live or persisted target, considers only direct descendants, and does not duplicate corpus merging, lineage reconstruction, or sibling ordering.
|
||||
- Listing loads no Agent, materializes no Activation, and appends no catalog or descriptor event itself. After the initial trace it calls `listEvents()` once per candidate and calls `readEvent()` only for a candidate with exactly one descriptor; persisted reads may trigger interrupted-tail repair, and compacted and uncompacted logs return the same children.
|
||||
- The session header gains no subagent `kind`; a supported valid descriptor is the sole subagent discriminator and includes the delegation's durable `label`. Ordinary session forks and one-shot children lack that descriptor and are omitted without a diagnostic.
|
||||
- Initial creation writes exactly one descriptor event, cold resume writes none, and a candidate with more than one descriptor event is diagnosed as `corrupt`.
|
||||
- `listChildren()` returns one ordered `SubagentListEntry[]`; its closed `kind: 'child' | 'diagnostic'` union carries only the fields defined above. Each valid child or per-child diagnostic occupies its traced candidate's `createdAt`-then-id position, while a missing descriptor produces no entry. Diagnostics are transient and require no query beyond the candidate operation that produced them.
|
||||
- `list_agents` takes no arguments, derives the parent id from the current execution Agent, and renders the complete result without a cursor. Child, diagnostic, and empty results use the fixed text forms defined above.
|
||||
- A live logical session is `running`; a persisted-only logical session is `complete`. The result does not inspect the process-local Activation map or provider availability and leaves delivery-time authority and residency checks to `send_message`.
|
||||
- Parent resume does not activate children. A child is absent until its session is published, and listing may race publication, disposal, or later delivery without weakening `send_message`'s execution-time checks.
|
||||
- `list_agents` uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons and never exposes descriptor contents in a diagnostic.
|
||||
- After a successful initial trace, a corrupt, unsupported, disappeared, or unreadable descriptor candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic. Corpus-level persistence, header-consistency, or lineage failure during that initial trace fails the whole call.
|
||||
- Per-child session-query failures map deterministically: invalid surfaces, exact-load source conflicts, immutable-header or direct-parent mismatches against the trace, and a changed read target are `corrupt`; missing sessions or events and persistence failures are `unavailable`; unknown descriptor versions are `unsupported`; and missing descriptors are omitted as non-continuable children.
|
||||
- The list tool requires `sessionQuery` at plugin load; a direct `listChildren()` call without it fails before enumeration with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, while by-id `send_message` remains usable without that service.
|
||||
- Keyless tests cover fresh and compacted discovery, ordinary fork and one-shot exclusion, live-to-complete transition, unmanaged-live-session snapshots, provider absence without child omission, durable `label` values, stable ordering, restart, direct-child tracing, duplicate descriptor rejection, isolated child diagnostics, phase-dependent persistence failure, load repair, snapshot races, and scan behavior. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage.
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, the subagent service, and a concrete session-query service) keylessly: fresh discovery through a real `startContinuable()` child; a persisted (restart-shaped) parent target; `createdAt`-then-id ordering with authored ties; ordinary-fork and fork-seed ancestor-descriptor exclusion without diagnostics; live `running` vs persisted `complete`; duplicate-descriptor, malformed-payload, invalid-surface, mismatched-header, and changed-read-target corruption diagnostics that leave healthy siblings visible; unsupported-version and per-child unavailable diagnostics; provider absence without child omission; compacted/uncompacted twins listing identically; grandchild exclusion; trace-phase failure failing the whole call while candidate-phase failures isolate to one child; configuration/window and unrecognized failures propagating as operation failures; and the `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` no-service contract.
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the no-agent rejection, load-time `sessionQuery` injection, and HMR disposal.
|
||||
- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) pins the model-visible transcript: a background delegation settles, and `list_agents` executes for real against the subagent service, session query, and JSONL persistence, rendering `<id> [complete] — <label>`.
|
||||
|
||||
## Risks
|
||||
## 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 can fail the initial trace and hide otherwise healthy siblings. Per-child isolation begins only after that trace succeeds.
|
||||
- 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.
|
||||
- Session-query reads may repair interrupted child logs and persist synthetic closing events even though listing creates no Agent. This is the existing persistence-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 resident Activations.
|
||||
- The query returns every direct continuable child and diagnostic without a service cursor or candidate cap. Stable ordering makes the result deterministic but does not bound model-context growth; service pagination or deletion remains a later product decision.
|
||||
- `running` and `complete` are process-local corpus snapshots, not delivery promises. Another process may activate a persisted child while this process reports it as `complete`; cross-process accuracy requires a shared lease.
|
||||
- Making the durable `label` a required descriptor field is a pre-release format change: the descriptor version stays 1, and logs written by pre-label builds fail descriptor validation as `corrupt` (consistent with the repo's no-compatibility stance before the first tagged release).
|
||||
+12
-21
@@ -1,6 +1,6 @@
|
||||
# Agent Note(agent 决策记录):持久化 subagent 目录与 list_agents
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-durable-subagent-catalog-and-list-agents.md) | 中文
|
||||
|
||||
@@ -10,9 +10,9 @@ Status: proposed
|
||||
|
||||
枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。
|
||||
|
||||
## 提案
|
||||
## 决策
|
||||
|
||||
将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child Session 契约、单独评审的功能。`SubagentService.listChildren(parentSessionId: SessionId)` 必须:
|
||||
parent 到 child 的枚举及 `list_agents` 是一个基于持久化 child Session 契约、单独评审的功能。`SubagentService.listChildren(parentSessionId: SessionId)` 执行以下操作:
|
||||
|
||||
- 使用 `ctx.sessionQuery.traceSession(parentSessionId)` 获取 parent 的直接且实时优先的 child 会话;
|
||||
- 读取并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child;
|
||||
@@ -21,7 +21,7 @@ Status: proposed
|
||||
- 将存活 child 报告为 `running`,只存在于持久化存储中的 child 报告为 `complete`;
|
||||
- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
|
||||
|
||||
描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。本提案会为描述符增加持久化 `label`,并要求列表查询诊断重复的描述符事件;它不能削弱现有事实,也不能发明第二种描述符表示。
|
||||
描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。该目录会为描述符增加持久化 `label`,并要求列表查询诊断重复的描述符事件;它不会削弱现有事实,也不会发明第二种描述符表示。
|
||||
|
||||
### 枚举决策
|
||||
|
||||
@@ -46,7 +46,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务
|
||||
- `kind: 'child'` 携带只读的 `id: SessionId`、持久化 `label: string` 和 `status: 'running' | 'complete'`;
|
||||
- `kind: 'diagnostic'` 携带只读的 `id: SessionId` 和 `reason: 'corrupt' | 'unsupported' | 'unavailable'`。
|
||||
|
||||
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。child 状态 `running` 表示逻辑记录在 `ctx.sessions` 中存活;`complete` 表示该记录只存在于持久化存储中。这些值既不是 `AgentStatus`,也不是管理器内部的 Activation 状态,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本提案范围内。
|
||||
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。child 状态 `running` 表示逻辑记录在 `ctx.sessions` 中存活;`complete` 表示该记录只存在于持久化存储中。这些值既不是 `AgentStatus`,也不是管理器内部的 Activation 状态,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。
|
||||
|
||||
面向模型的 `list_agents` 工具不接受参数,从当前正在执行的 Agent 推导 `parentSessionId`,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它按数组顺序将 child 渲染为 `<id> [<status>] — <label>`,将 diagnostic 渲染为 `<id> [diagnostic: <reason>]`;空数组渲染为 `(no subagents)`。
|
||||
|
||||
@@ -82,27 +82,18 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
|
||||
|
||||
**立即为查询分页或设置上限(暂缓)。** 这可以限制一次结果的大小,但会使模型发现成为有状态操作,而且除非模型继续跟随 cursor,否则可能隐藏更早的 child。第一版没有 cursor、分页参数或候选数量上限配置,而是返回经稳定排序的完整集合;如果实测规模需要限制,服务级限制仍留待后续决策。
|
||||
|
||||
## 验收标准
|
||||
## 测试
|
||||
|
||||
- `listChildren(parentSessionId: SessionId)` 使用 `ctx.sessionQuery.traceSession(parentSessionId)`,接受存活或只存在于持久化存储中的目标,只考虑直接后代,并且不重复实现语料合并、谱系重建或 sibling 排序。
|
||||
- 列表查询不会加载 Agent、物化 Activation,也不会自行追加目录或描述符事件。初始追踪完成后,它会对每个候选调用一次 `listEvents()`,且只对恰好含有一个描述符的候选调用 `readEvent()`;持久化读取可能触发中断尾部修复,且经过压缩和未经压缩的日志会返回相同的 child。
|
||||
- 会话 header 不新增 subagent `kind`;受支持且有效的描述符是唯一的 subagent 判别信息,并包含委派的持久化 `label`。普通会话 fork 和一次性 child 缺少该描述符,因此会被排除且不产生 diagnostic。
|
||||
- 初始创建恰好写入一个描述符事件,从持久化存储恢复时不写入任何描述符;如果候选包含多个描述符事件,则将其诊断为 `corrupt`。
|
||||
- `listChildren()` 返回一个有序的 `SubagentListEntry[]`;其封闭的 `kind: 'child' | 'diagnostic'` 联合类型只携带上文定义的字段。每个有效 child 或逐 child diagnostic 占据其追踪候选按 `createdAt`、再按 id 排列的位置,缺少描述符则不产生条目。diagnostic 是瞬时结果,除了产生该结果的候选操作外不需要其他查询。
|
||||
- `list_agents` 不接受参数,从当前正在执行的 Agent 推导 parent id,并在没有 cursor 的情况下渲染完整结果。child、diagnostic 和空结果使用上文定义的固定文本形式。
|
||||
- 存活的逻辑会话为 `running`;只存在于持久化存储中的逻辑会话为 `complete`。结果不检查进程内 Activation map 或提供方可用性,并将消息送达时的鉴权与驻留状态检查留给 `send_message`。
|
||||
- 恢复 parent 不会激活 child。child 会话发布前不会出现,列表查询可能与发布、dispose 或后续消息送达发生竞态,但不会削弱 `send_message` 在执行时进行的检查。
|
||||
- `list_agents` 只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。
|
||||
- 初始追踪成功后,描述符损坏、不受支持、已消失或无法读取的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic。初始追踪期间发生的语料级持久化、header 一致性或谱系失败会让整次调用失败。
|
||||
- 逐 child 会话查询失败采用固定映射:无效 surface、精确加载时的来源冲突、相对于追踪结果的不可变 header 或直接 parent 不匹配,以及已变化的读取目标映射为 `corrupt`;会话或事件缺失以及持久化失败映射为 `unavailable`;未知描述符版本映射为 `unsupported`;缺少描述符则作为不可继续 child 排除。
|
||||
- 列表工具在插件加载时要求 `sessionQuery`;直接调用 `listChildren()` 时如果缺少该服务,则会在枚举前以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 失败,而按 id 的 `send_message` 在没有该服务时仍可使用。
|
||||
- 无密钥测试覆盖压缩前后的发现、排除普通 fork 和一次性 child、从存活到 complete 的转换、未受管理的存活会话快照、提供方缺失时不排除 child、持久化 `label` 值、稳定排序、重启、直接 child 追踪、重复描述符拒绝、单个 child diagnostic 隔离、依阶段而异的持久化失败、加载修复、快照竞态和扫描行为。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、subagent 服务,以及一个具体的会话查询服务):通过真实 `startContinuable()` child 的全新发现;只存在于持久化存储中(重启形态)的 parent 目标;带有人工构造并列项的按 `createdAt` 再按 id 排序;排除普通 fork 和 fork seed 中祖先描述符且不产生 diagnostic;存活 `running` 与持久化 `complete` 的对比;重复描述符、载荷格式错误、无效 surface、header 不匹配和读取目标已变化的损坏 diagnostic 均不隐藏健康的 sibling;不受支持版本与逐 child unavailable diagnostic;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;排除孙代会话;追踪阶段失败导致整次调用失败而候选阶段失败只隔离到单个 child;配置/窗口错误和无法识别的失败作为操作失败向上传播;以及 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 缺服务契约。
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、无调用 agent 时的拒绝、加载时的 `sessionQuery` 注入,以及 HMR dispose。
|
||||
- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)固定模型可见的转写:一次后台委派结束后,`list_agents` 针对 subagent 服务、会话查询和 JSONL 持久化真实执行,渲染 `<id> [complete] — <label>`。
|
||||
|
||||
## 风险
|
||||
## 影响
|
||||
|
||||
- 会话追踪会观察完整的逻辑语料,随后描述符校验会读取每个直接 child 的日志一次,并对恰好含有一个描述符的候选读取两次。对于只存在于持久化存储中的最坏情况,工作量为 O(D × C + Σ L_i),而不只是 O(D),因为每次精确读取都会重新扫描持久化存储,并加载和克隆候选的完整日志。后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。
|
||||
- 语料构建是一个全有或全无的信任边界:一处存活/持久化 header 冲突就可能导致初始追踪失败,并隐藏原本健康的 sibling。只有初始追踪成功后,逐 child 隔离才会生效。
|
||||
- 语料构建是一个全有或全无的信任边界:一处存活/持久化 header 冲突就会导致初始追踪失败,并隐藏原本健康的 sibling。只有初始追踪成功后,逐 child 隔离才会生效。
|
||||
- 会话查询读取可能修复中断的 child 日志并持久化合成的结束事件,即使列表查询不创建 Agent。这是现有的持久化加载契约,而非隐藏的目录写入。
|
||||
- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由驻留 Activation 数量限制。
|
||||
- 查询会返回每个直接可继续 child 和 diagnostic,不设服务 cursor 或候选数量上限。稳定排序可使结果确定,但不会限制模型上下文的增长;服务分页或删除仍是后续的产品决策。
|
||||
- `running` 和 `complete` 是进程内语料快照,而非消息送达承诺。另一个进程可能在当前进程将某个持久化 child 报告为 `complete` 时激活它;跨进程准确性需要共享租约。
|
||||
- 将持久化 `label` 设为描述符必填字段是一次发布前的格式变更:描述符版本保持为 1,加入 label 之前的构建所写入的日志会在描述符校验中判为 `corrupt`(与仓库首个正式版本之前不做兼容承诺的立场一致)。
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: 23d5b3924a20ae84132048b26e12779a98a6f2bb
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: cac14271515d031a39bf6690f199c97be7bb9fa9
|
||||
@@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -811,7 +811,7 @@ A provider became resolvable in the registry.
|
||||
|
||||
Types: [SubagentProvider](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:110`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:116`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:127`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
|
||||
@@ -1992,6 +1992,17 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti
|
||||
*/
|
||||
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
|
||||
|
||||
/**
|
||||
* Enumerate one session's direct continuable children from the durable,
|
||||
* live-preferred corpus without loading or resuming an Agent. The lineage
|
||||
* trace supplies stable candidate order and live status; each candidate is
|
||||
* then inspected independently for exactly one supported descriptor in its
|
||||
* own suffix.
|
||||
* @param parentSessionId - parent whose direct children are listed.
|
||||
* @returns child and diagnostic entries in lineage-trace order.
|
||||
*/
|
||||
async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]>
|
||||
|
||||
/**
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
@@ -2026,9 +2037,9 @@ list(): string[]
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.subprocess` — `SubprocessService` (abstract seam)
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md
|
||||
subagent.md: 379bc9fdd5ff14f9d516d3ddcf37a353f5318026
|
||||
subagent.zh.md: 3cd43deadba095e67e6a0dd3b483ea676206fbbf
|
||||
subagent.md: ae0d84af09e65bf284780722bab02282fd6c3f37
|
||||
subagent.zh.md: 9d2ba038885110a52cc3c7153cc908b05ea8b15f
|
||||
@@ -200,7 +200,11 @@ interface ContinuableCreateSpec {
|
||||
}
|
||||
```
|
||||
|
||||
The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never 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` (a one-shot result contract, not durable composition). The continuation manager appends the model-hidden `subagent/descriptor` event after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log.
|
||||
The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, the delegation `description` as the durable creation `label`, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never 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` (a one-shot result contract, not durable composition). The continuation manager appends the model-hidden `subagent/descriptor` event after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log.
|
||||
|
||||
## Durable enumeration: `listChildren()` and `SubagentListEntry`
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct continuable children from one `ctx.sessionQuery.traceSession()` observation, without loading or resuming any Agent. Session lineage is broader than subagent identity — ordinary forks and one-shot children share `parentSession` — so exactly one supported `subagent/descriptor` event in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) is the sole subagent discriminator. The result is one `SubagentListEntry[]` in the trace's `createdAt`-then-id candidate order: a valid descriptor yields a `child` entry whose `status` snapshots the logical record (`running` = live in `ctx.sessions`, `complete` = persisted only, resumable by `send_message`); a per-child inspection failure yields a `diagnostic` entry (`corrupt`, `unsupported`, or `unavailable`) so one damaged sibling cannot hide healthy children; a missing descriptor yields no entry. A failure while building the initial trace fails the whole call — per-child isolation begins only after a trustworthy candidate set exists. The service keeps `sessionQuery` optional for by-id continuation: `listChildren()` throws `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` when it is absent, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) requires the service at plugin load. Listing does not consult the continuation manager's Activation map or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed `running` child may still reject delivery as an ownership conflict.
|
||||
|
||||
## The terminal result: `SubagentResult`
|
||||
|
||||
|
||||
@@ -200,7 +200,11 @@ interface ContinuableCreateSpec {
|
||||
}
|
||||
```
|
||||
|
||||
描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、作为持久化创建 `label` 的委派 `description`、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
|
||||
## 持久化枚举:`listChildren()` 与 `SubagentListEntry`
|
||||
|
||||
`SubagentService.listChildren(parentSessionId)` 从一次 `ctx.sessionQuery.traceSession()` 观测中枚举 parent 的直接可继续 child,而不会加载或恢复任何 Agent。会话谱系的范围比 subagent 身份更广——普通 fork 和一次性 child 都会共享 `parentSession`——因此,child 自身后缀中恰好一个受支持的 `subagent/descriptor` 事件(位于 `seedLength` 之后,避免 fork seed 泄漏祖先描述符)是唯一的 subagent 判别信息。结果是一个按追踪结果中 `createdAt`、再按 id 排列候选顺序的 `SubagentListEntry[]`:有效描述符生成 `child` 条目,其 `status` 是逻辑记录的快照(`running` 表示在 `ctx.sessions` 中存活,`complete` 表示只存在于持久化存储中并可由 `send_message` 恢复);逐 child 检查失败生成 `diagnostic` 条目(`corrupt`、`unsupported` 或 `unavailable`),因此一个损坏的 sibling 不会隐藏健康 child;缺少描述符则不生成条目。构建初始追踪时的失败会让整个调用失败——只有得到可信候选集后才开始逐 child 隔离。服务将 `sessionQuery` 保持为按 id 继续执行时的可选依赖:缺少该服务时,`listChildren()` 抛出 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`,而面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)会在插件加载时要求该服务。枚举不会查询继续执行管理器的 Activation map 或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的 `running` child 仍可能因所有权冲突而拒绝投递。
|
||||
|
||||
## 终态结果:`SubagentResult`
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:110`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:116`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:127`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
+15
-2
@@ -31,7 +31,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.sessionQuery (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query). |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
|
||||
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. |
|
||||
| `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
|
||||
@@ -1149,6 +1149,19 @@ The registered tool name is the load-time `toolName` config (default `subagent`)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-subagent-control`
|
||||
|
||||
### `list_agents`
|
||||
|
||||
List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)
|
||||
|
||||
### `send_message`
|
||||
|
||||
Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.
|
||||
@@ -1175,7 +1188,7 @@ Send a message to a background subagent by its subagent id, continuing the same
|
||||
|
||||
Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)
|
||||
|
||||
The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once.
|
||||
The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).
|
||||
|
||||
## `@deepseek-ai/dsh-tool-tasks`
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ flowchart LR
|
||||
cfg --> plugin_acp_subagent_fork
|
||||
plugin_acp_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
|
||||
cfg --> plugin_acp_tool_subagent_control
|
||||
plugin_acp_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
|
||||
cfg --> plugin_acp_tool_subagent_list_agents
|
||||
plugin_acp_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
|
||||
cfg --> plugin_acp_tool_subagent
|
||||
plugin_acp_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
|
||||
@@ -82,6 +84,7 @@ flowchart LR
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
|
||||
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
|
||||
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
|
||||
|
||||
@@ -97,10 +97,15 @@
|
||||
providerName: fork
|
||||
|
||||
# Continuable background children are selected per delegation tool. The
|
||||
# separately loaded follow-up tool registers the one global `send_message`.
|
||||
# separately loaded control package registers the one global `send_message`
|
||||
# shared by both delegation tools. Its list plugin additionally requires the
|
||||
# session query service supplied by the ACP app.
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
|
||||
@@ -228,6 +228,10 @@ const SCENARIOS: Scenario[] = [
|
||||
recorded: false,
|
||||
configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG,
|
||||
},
|
||||
// Authored durable-catalog transcript: after a background delegation settles,
|
||||
// `list_agents` executes for real against the control service, session query,
|
||||
// and JSONL persistence, and renders the child's durable label as complete.
|
||||
{ name: 'subagent-list-agents', hasModelTurn: true, recorded: false },
|
||||
{
|
||||
name: 'subagent-depth-two-rejection',
|
||||
hasModelTurn: true,
|
||||
|
||||
@@ -94,6 +94,8 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped. */
|
||||
list_agents: Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
@@ -302,6 +304,16 @@ interface ToolOutputMap {
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "complete";
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
|
||||
@@ -172,6 +172,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -77,6 +77,8 @@ interface ToolArgsMap {
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped. */
|
||||
list_agents: Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
@@ -273,6 +275,16 @@ interface ToolOutputMap {
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "complete";
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lsp",
|
||||
"description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.",
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1}
|
||||
{"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
|
||||
{"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":1,"provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
|
||||
{"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}}
|
||||
{"type":"turn/start","seq":2,"time":1785544945199,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":3,"time":1785544945199,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"cd28c816-821e-412c-bc7f-404bbb599641"},"surfaceOp":"append"}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "initialize"
|
||||
},
|
||||
{
|
||||
"op": "newSession"
|
||||
},
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Follow these steps exactly, then stop. 1. 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.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call list_agents and observe the subagent you started. 4. Reply with the single word DONE. Do not use the bash tool."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1}
|
||||
{"type":"subagent/descriptor","seq":0,"time":1785531795641,"data":{"version":1,"provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
|
||||
{"type":"session/end-seed","seq":1,"time":1785531795641,"data":{}}
|
||||
{"type":"turn/start","seq":2,"time":1785531795641,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":3,"time":1785531795642,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"33a98a9e-5402-4a7e-b919-05d179da1b84"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":4,"time":1785531795642,"data":{"title":"Reply with exactly the word","messageSeqs":[3],"source":{"kind":"fallback"}}}
|
||||
{"type":"user/message","seq":5,"time":1785531795671,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"abfeb4b9-d3d3-47fc-b3fc-534d057ef5c2"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":6,"time":1785531795671,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":7,"time":1785531795672,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1785531795672,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":1785531795683,"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":"65c53b2a-3ffa-4537-82fd-e6ce33df2c6e"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":1785531795683,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":16,"time":1785531795683,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. 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.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call list_agents and observe the subagent you started. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"f0bb39a7-3fc0-44e9-b3b9-5df1fc3a2a32"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"user/message","seq":3,"time":1785531795622,"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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"00d07016-a9d1-4733-a714-d4eaeac5df87"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":4,"time":1785531795622,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":5,"time":1785531795623,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":6,"time":1785531795623,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"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":"assistant/chunk","seq":10,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1785531795632,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":12,"time":1785531795632,"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":"75877603-3b80-4c08-8c55-4ee9899ffeb5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":13,"time":1785531795632,"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":"tool/result","seq":14,"time":1785531795642,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"c702b49d-200f-44ca-8112-e6f44b9f9c54"}},"sourceEventSeqs":[13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":1785531795642,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":16,"time":1785531795650,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":17,"time":1789000000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_1","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1785531795656,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":22,"time":1785531795656,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"18452edf-194a-4372-9851-8b1a7738360e"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":23,"time":1785531795656,"data":{"turn":1,"step":2,"callId":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}
|
||||
{"type":"tool/result","seq":24,"time":1785531795668,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"Error: unknown task subagent-1"}],"isError":true}],"role":"user","id":"7df847e3-c725-4ba4-94cc-85079e25b501"}},"sourceEventSeqs":[23],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":25,"time":1785531795668,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":26,"time":1785531795680,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list","name":"list_agents","argumentsDelta":"{}"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785531795686,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1785531795686,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":32,"time":1785531795686,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f720c7a5-97fb-49dc-aefa-99c89f2d37bc"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":33,"time":1785531795686,"data":{"turn":1,"step":3,"callId":"call_list","name":"list_agents","arguments":"{}"}}
|
||||
{"type":"tool/result","seq":34,"time":1785531795703,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_list"},"content":[{"type":"tool-result","toolCallId":"call_list","content":[{"type":"text","text":"33333333-3333-4333-8333-333333333333 [complete] — Reply with CHILD_OK"}],"isError":false}],"role":"user","id":"0a8e97a2-a206-4cee-b449-59f3ce21f67c"}},"sourceEventSeqs":[33],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":35,"time":1785531795703,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":36,"time":1785531795710,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":37,"time":1789000000036,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":1785531795715,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":1785531795715,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":42,"time":1785531795715,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a4c63b7-4ecf-4efc-8f4a-c0894b211475"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":43,"time":1785531795716,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":44,"time":1785531795716,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your background subagents: every subagent you started that can receive `send_message`, whether it is still working (running) or has finished its current turn (complete — a follow-up message starts a new turn on the same conversation). Children that could not be read are reported as diagnostics instead of being silently dropped.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
|
||||
@@ -896,6 +896,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>',
|
||||
jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]>',
|
||||
jsDoc: '/**\n * Enumerate one session\'s direct continuable children from the durable,\n * live-preferred corpus without loading or resuming an Agent. The lineage\n * trace supplies stable candidate order and live status; each candidate is\n * then inspected independently for exactly one supported descriptor in its\n * own suffix.\n * @param parentSessionId - parent whose direct children are listed.\n * @returns child and diagnostic entries in lineage-trace order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerProvider(provider: SubagentProvider): () => void',
|
||||
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
|
||||
@@ -1809,7 +1813,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ContinuableStartSpec',
|
||||
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly request: Omit<SubagentStartRequest, \'signal\' | \'outputSchema\'>;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit<SubagentStartRequest, \'signal\' | \'outputSchema\'>;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateAgentOptions',
|
||||
@@ -2691,6 +2695,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SubagentFollowupOptions',
|
||||
declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentListEntry',
|
||||
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly label: string;\n readonly status: \'running\' | \'complete\';\n} | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'SubagentProvider',
|
||||
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -42,6 +43,9 @@
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -54,6 +58,7 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -59,6 +59,8 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
export interface ContinuableStartSpec {
|
||||
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
|
||||
readonly provider: string
|
||||
/** The initial delegation's short `description`, persisted as the child's creation label. */
|
||||
readonly label: string
|
||||
/**
|
||||
* The delegation request. The manager reserves the stable child id, resolves
|
||||
* the durable descriptor, and composes the child itself.
|
||||
@@ -297,6 +299,7 @@ export class SubagentContinuationManager {
|
||||
const agentModel = request.agentOptions?.model ?? parent.options.model
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
provider: spec.provider,
|
||||
label: spec.label,
|
||||
...agentProvider !== undefined ? { agentProvider } : {},
|
||||
...agentModel !== undefined ? { agentModel } : {},
|
||||
...request.persona !== undefined ? { persona: request.persona } : {},
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface SubagentDescriptorData {
|
||||
readonly version: number
|
||||
/** The `ctx.subagents` provider name that established the child. */
|
||||
readonly provider: string
|
||||
/**
|
||||
* The initial delegation's short `description`, kept as the child's durable
|
||||
* creation label so enumeration can identify the conversation without
|
||||
* replaying parent tool results or exposing the child prompt.
|
||||
*/
|
||||
readonly label: string
|
||||
/** Resolved child `agentOptions.provider`, when one was declared. */
|
||||
readonly agentProvider?: string
|
||||
/** Resolved child `agentOptions.model`, when one was declared. */
|
||||
@@ -61,6 +67,8 @@ export interface SubagentDescriptorData {
|
||||
export interface SubagentDescriptorInput {
|
||||
/** The `ctx.subagents` provider name that will establish the child. */
|
||||
readonly provider: string
|
||||
/** The initial delegation's short `description`, the durable creation label. */
|
||||
readonly label: string
|
||||
/** Requested child `agentOptions.provider`. */
|
||||
readonly agentProvider?: string
|
||||
/** Requested child `agentOptions.model`. */
|
||||
@@ -74,6 +82,7 @@ export interface SubagentDescriptorInput {
|
||||
const DESCRIPTOR_KEYS = new Set([
|
||||
'version',
|
||||
'provider',
|
||||
'label',
|
||||
'agentProvider',
|
||||
'agentModel',
|
||||
'persona',
|
||||
@@ -151,6 +160,10 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
if (typeof provider !== 'string') {
|
||||
throw new Error('persisted subagent descriptor provider must be a string')
|
||||
}
|
||||
const label = value['label']
|
||||
if (typeof label !== 'string') {
|
||||
throw new Error('persisted subagent descriptor label must be a string')
|
||||
}
|
||||
const agentProvider = optionalString(value, 'agentProvider')
|
||||
const agentModel = optionalString(value, 'agentModel')
|
||||
const persona = optionalString(value, 'persona')
|
||||
@@ -160,6 +173,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
return {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider,
|
||||
label,
|
||||
...agentProvider !== undefined ? { agentProvider } : {},
|
||||
...agentModel !== undefined ? { agentModel } : {},
|
||||
...persona !== undefined ? { persona } : {},
|
||||
@@ -180,6 +194,7 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
|
||||
const candidate: SubagentDescriptorData = {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider: input.provider,
|
||||
label: input.label,
|
||||
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
|
||||
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
|
||||
...input.persona !== undefined ? { persona: input.persona } : {},
|
||||
|
||||
@@ -36,6 +36,11 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertSessionHeadersCompatible,
|
||||
SessionQueryError,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
@@ -47,6 +52,7 @@ import type {
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
|
||||
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
|
||||
@@ -96,6 +102,28 @@ export type {
|
||||
} from './continuation.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
/**
|
||||
* One direct-child enumeration result. Descriptor-less ordinary children are
|
||||
* omitted; a per-child inspection failure remains visible as a diagnostic.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
/** Durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
/** Whether the child is currently live or exists only in persistence. */
|
||||
readonly status: 'running' | 'complete'
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** Traced candidate session id. */
|
||||
readonly id: SessionId
|
||||
/** Fixed reason the candidate could not be returned as a child. */
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
subagents: SubagentService
|
||||
@@ -218,6 +246,80 @@ export class SubagentService extends Service {
|
||||
await manager.drainDescendants(parents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate one session's direct continuable children from the durable,
|
||||
* live-preferred corpus without loading or resuming an Agent. The lineage
|
||||
* trace supplies stable candidate order and live status; each candidate is
|
||||
* then inspected independently for exactly one supported descriptor in its
|
||||
* own suffix.
|
||||
* @param parentSessionId - parent whose direct children are listed.
|
||||
* @returns child and diagnostic entries in lineage-trace order.
|
||||
*/
|
||||
async listChildren(parentSessionId: SessionId): Promise<SubagentListEntry[]> {
|
||||
const query = this.ctx.get('sessionQuery')
|
||||
if (query === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires session query (load a dsh-session-query backend)',
|
||||
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
const trace = await query.traceSession(parentSessionId)
|
||||
const entries: SubagentListEntry[] = []
|
||||
for (const node of trace.descendants) {
|
||||
const entry = await this.inspectChild(query, parentSessionId, node.session)
|
||||
if (entry !== undefined) entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Inspect one traced candidate without materializing its Agent. */
|
||||
private async inspectChild(
|
||||
query: SessionQueryService,
|
||||
parentSessionId: SessionId,
|
||||
candidate: SessionRecord,
|
||||
): Promise<SubagentListEntry | undefined> {
|
||||
const childId = candidate.header.id
|
||||
try {
|
||||
const records = await query.listEvents(childId)
|
||||
// Fork seeds replay ancestor events, so only this child's suffix owns its descriptor.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
.map(record => record.seq)
|
||||
if (descriptorSeqs.length === 0) return undefined
|
||||
if (descriptorSeqs.length > 1) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this index exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await query.readEvent({ sessionId: childId, seq })
|
||||
assertSessionHeadersCompatible(window.session, candidate.header)
|
||||
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let descriptor: ReturnType<typeof foldSubagentDescriptor>
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([window.target])
|
||||
} catch {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
|
||||
}
|
||||
return {
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
label: descriptor.label,
|
||||
status: candidate.live ? 'running' : 'complete',
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error)
|
||||
if (reason === undefined) throw error
|
||||
return { kind: 'diagnostic', id: childId, reason }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
@@ -349,3 +451,19 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
export default SubagentService
|
||||
|
||||
/** Map isolated session-query failures to the fixed child diagnostic taxonomy. */
|
||||
function perChildDiagnosticReason(error: unknown): 'corrupt' | 'unavailable' | undefined {
|
||||
if (!(error instanceof SessionQueryError)) return undefined
|
||||
switch (error.code) {
|
||||
case 'SESSION_QUERY_SESSION_NOT_FOUND':
|
||||
case 'SESSION_QUERY_EVENT_NOT_FOUND':
|
||||
case 'SESSION_QUERY_PERSISTENCE_FAILED':
|
||||
return 'unavailable'
|
||||
case 'SESSION_QUERY_INVALID_SURFACE':
|
||||
case 'SESSION_QUERY_SOURCE_CONFLICT':
|
||||
return 'corrupt'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const testSignal = new AbortController().signal
|
||||
function startSpec(parent: Agent, provider = 'spawn', signal: AbortSignal = testSignal) {
|
||||
return {
|
||||
provider,
|
||||
label: 'child task',
|
||||
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
|
||||
signal,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import SubagentService, {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
SubagentError,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the continuable stack plus a concrete session-query service. */
|
||||
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
const testSignal = new AbortController().signal
|
||||
|
||||
/** Start one continuable child through the real service path and await Activation release. */
|
||||
async function startChild(
|
||||
ctx: Context,
|
||||
parent: ReturnType<Context['agentLoop']['create']>,
|
||||
label: string,
|
||||
): Promise<SessionId> {
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label,
|
||||
request: { prompt: [{ type: 'text', text: `task: ${label}` }], parent },
|
||||
signal: testSignal,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
}, { timeout: 5_000 })
|
||||
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[],
|
||||
): Promise<SessionId> {
|
||||
const sessionId = SessionId(id)
|
||||
await ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: 1,
|
||||
...header,
|
||||
})
|
||||
await ctx.sessionPersistence.append(sessionId, events)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/** Minimal complete-turn child log with one descriptor payload. */
|
||||
function childEvents(descriptor: unknown): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptor },
|
||||
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
}
|
||||
|
||||
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
|
||||
return { version, provider: 'spawn', label }
|
||||
}
|
||||
|
||||
describe('SubagentService.listChildren', () => {
|
||||
it('fails loud before any work when session query is not loaded', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a persisted continuable child as complete with its durable label', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'summarize the doc')
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'summarize the doc', status: 'complete' },
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a persisted (non-live) parent target after restart', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A parent that exists only in persistence — the restart shape.
|
||||
const coldParent = SessionId('00000000-0000-4000-8000-00000000cccc')
|
||||
await ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: coldParent,
|
||||
createdAt: 1,
|
||||
})
|
||||
await ctx.sessionPersistence.append(coldParent, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000cdcd', {
|
||||
parentSession: coldParent,
|
||||
}, childEvents(descriptorPayload('persisted parent case')))
|
||||
const entries = await ctx.subagents.listChildren(coldParent)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'persisted parent case', status: 'complete' },
|
||||
])
|
||||
})
|
||||
|
||||
it('orders children by createdAt then id and omits ordinary forks without a diagnostic', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Authored headers pin the ordering key deterministically: same createdAt
|
||||
// ties break on id, different createdAt orders ascending.
|
||||
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 9,
|
||||
}, childEvents(descriptorPayload('late child')))
|
||||
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
}, childEvents(descriptorPayload('tie b')))
|
||||
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
}, childEvents(descriptorPayload('tie a')))
|
||||
// An ordinary session fork shares parentSession but has no descriptor.
|
||||
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
|
||||
await ctx.sessions.flush(fork)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
|
||||
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a live child as running while keeping settled siblings complete', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const settled = await startChild(ctx, parent, 'settled child')
|
||||
// A live child session outside persistence: publish a live session with a
|
||||
// descriptor and the parent lineage, without starting an Activation.
|
||||
const liveId = SessionId('live-child')
|
||||
const live = ctx.sessions.create(liveId, { meta: { parentSession: parent.id } })
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append('subagent/descriptor', descriptorPayload('live child'))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'child', id: settled, label: 'settled child', status: 'complete' })
|
||||
expect(entries).toContainEqual({ kind: 'child', id: liveId, label: 'live child', status: 'running' })
|
||||
})
|
||||
|
||||
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const events = childEvents(descriptorPayload('twice'))
|
||||
events.splice(3, 0, {
|
||||
type: 'subagent/descriptor',
|
||||
seq: 3,
|
||||
time: 3,
|
||||
data: descriptorPayload('twice again'),
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
|
||||
parentSession: parent.id,
|
||||
}, events)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', status: 'complete' })
|
||||
})
|
||||
|
||||
it('diagnoses an invalid child event surface as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp, so the
|
||||
// per-child listEvents fold fails with SESSION_QUERY_INVALID_SURFACE.
|
||||
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
|
||||
parentSession: parent.id,
|
||||
}, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
|
||||
},
|
||||
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
|
||||
] as SessionEvent[])
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a malformed descriptor payload as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
|
||||
parentSession: parent.id,
|
||||
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 7 }))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses an unknown descriptor version as unsupported', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
|
||||
parentSession: parent.id,
|
||||
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
|
||||
})
|
||||
|
||||
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// A fork child whose seed replays a parent log containing a descriptor:
|
||||
// the seed's descriptor is the ANCESTOR's, not this child's.
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
}, seed)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([])
|
||||
})
|
||||
|
||||
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
|
||||
parentSession: parent.id,
|
||||
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'not-mounted', label: 'orphan provider' }))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: foreign, label: 'orphan provider', status: 'complete' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'flaky storage')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
query.listEvents = (sessionId) => {
|
||||
if (sessionId === childId) {
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
return originalListEvents(sessionId)
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
})
|
||||
|
||||
it('maps a mid-scan disappearance to unavailable', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'vanishing child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('gone', 'SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'reparented child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return {
|
||||
...window,
|
||||
session: { ...window.session, parentSession: SessionId('someone-else') },
|
||||
}
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
// The exact read's conflicting immutable header is per-child corruption.
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'shifted log')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('fails the whole call when the initial trace fails', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'never listed')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.traceSession = () =>
|
||||
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates an unrecognized per-child failure as an operation failure', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'strange failure')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => Promise.reject(new Error('not a query failure'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
|
||||
})
|
||||
|
||||
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'misconfigured query')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists compacted and uncompacted children identically', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const plain = await authorChild(ctx, '00000000-0000-4000-8000-00000000c0de', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 1,
|
||||
}, childEvents(descriptorPayload('twin child')))
|
||||
// The compacted twin: a compaction checkpoint replaces the whole surface,
|
||||
// while the append-only log retains the model-hidden descriptor event.
|
||||
const compactedEvents = childEvents(descriptorPayload('twin child'))
|
||||
compactedEvents.push({
|
||||
type: 'user/message',
|
||||
seq: 4,
|
||||
time: 5,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary of everything' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
})
|
||||
const compacted = await authorChild(ctx, '00000000-0000-4000-8000-00000000c1de', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 2,
|
||||
}, compactedEvents)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: plain, label: 'twin child', status: 'complete' },
|
||||
{ kind: 'child', id: compacted, label: 'twin child', status: 'complete' },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes grandchildren: only direct descendants are candidates', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
|
||||
parentSession: childId,
|
||||
}, childEvents(descriptorPayload('grandchild')))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for a parent with no children', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await ctx.sessions.flush(parent.session)
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('SubagentError from listChildren is typed with its stable code', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
|
||||
})
|
||||
})
|
||||
@@ -130,6 +130,7 @@ describe('SubagentService', () => {
|
||||
const { subagents } = await service()
|
||||
await expect(subagents.startContinuable({
|
||||
provider: 'unused',
|
||||
label: 'unused child',
|
||||
request: baseRequest(),
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
|
||||
@@ -298,15 +299,17 @@ describe('subagent descriptors', () => {
|
||||
|
||||
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
|
||||
expect(foldSubagentDescriptor([])).toBeUndefined()
|
||||
const minimal = snapshotSubagentDescriptor({ provider: 'spawn' })
|
||||
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
|
||||
expect(minimal).toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider: 'spawn',
|
||||
label: 'child work',
|
||||
})
|
||||
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
|
||||
const complete = {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
provider: 'spawn',
|
||||
label: 'complete child',
|
||||
agentProvider: 'deepseek',
|
||||
agentModel: 'chat',
|
||||
persona: 'reviewer',
|
||||
@@ -314,6 +317,7 @@ describe('subagent descriptors', () => {
|
||||
}
|
||||
expect(snapshotSubagentDescriptor({
|
||||
provider: complete.provider,
|
||||
label: complete.label,
|
||||
agentProvider: complete.agentProvider,
|
||||
agentModel: complete.agentModel,
|
||||
persona: complete.persona,
|
||||
@@ -321,16 +325,17 @@ describe('subagent descriptors', () => {
|
||||
})).toEqual(complete)
|
||||
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }),
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
|
||||
])).toMatchObject({ toolFilter: { allow: ['read'] } })
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }),
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { deny: ['bash'] } }),
|
||||
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
|
||||
])).toBeUndefined()
|
||||
expect(() => snapshotSubagentDescriptor({
|
||||
provider: 'spawn',
|
||||
label: 'bad',
|
||||
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
|
||||
})).toThrow('not losslessly JSON-serializable')
|
||||
})
|
||||
@@ -343,15 +348,17 @@ describe('subagent descriptors', () => {
|
||||
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
|
||||
['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'],
|
||||
['missing provider', { version: 1 }, 'provider must be a string'],
|
||||
['missing label', { version: 1, provider: 'spawn' }, 'label must be a string'],
|
||||
['invalid label', { version: 1, provider: 'spawn', label: 7 }, 'label must be a string'],
|
||||
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
|
||||
['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'],
|
||||
['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'],
|
||||
['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'],
|
||||
['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'],
|
||||
['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
|
||||
['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
|
||||
['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
|
||||
['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
|
||||
['invalid agent provider', { version: 1, provider: 'spawn', label: 'l', agentProvider: 7 }, 'agentProvider must be a string'],
|
||||
['invalid agent model', { version: 1, provider: 'spawn', label: 'l', agentModel: [] }, 'agentModel must be a string'],
|
||||
['invalid persona', { version: 1, provider: 'spawn', label: 'l', persona: {} }, 'persona must be a string'],
|
||||
['non-object tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: [] }, 'toolFilter must be an object'],
|
||||
['unknown tool-filter field', { version: 1, provider: 'spawn', label: 'l', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
|
||||
['empty tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
|
||||
['non-array allow list', { version: 1, provider: 'spawn', label: 'l', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
|
||||
['non-string deny item', { version: 1, provider: 'spawn', label: 'l', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
|
||||
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
|
||||
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
|
||||
README.md: 5023862cba39769248a9f6cbe935d6397df39266
|
||||
README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8
|
||||
README.md: 1fe0e49006d12b95e6c6e38cab7895238b3d3c98
|
||||
README.zh.md: 9d2d46888b0790548df35cb54613ee2334143db7
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents` and additionally requires `sessionQuery` at load. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work.
|
||||
|
||||
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
`list_agents` takes no arguments, derives the parent id from the calling agent, and renders `ctx.subagents.listChildren()`'s complete entry array without a cursor. It is discovery only: durable identity comes from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schema
|
||||
@@ -36,7 +38,23 @@ One short acknowledgement per call; the child's response never returns through t
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Listing result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One line per entry in the trace's stable order: `<id> [<status>] — <label>` for a child (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`), and `(no subagents)` for an empty result. Diagnostics never expose descriptor contents.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Grows linearly with the parent's direct continuable children; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; each result follows the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work on that turn lands in the durable child Session, read by its subagent id, and is neither delivered back nor collected through this tool.
|
||||
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease.
|
||||
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,并在加载时额外要求 `sessionQuery`。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。
|
||||
|
||||
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
`list_agents` 不接受参数,从调用 Agent 推导 parent id,并在没有 cursor 的情况下渲染 `ctx.subagents.listChildren()` 的完整条目数组。它只负责发现:持久化身份来自每个 child 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
@@ -36,7 +38,23 @@
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 列表结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个条目按追踪结果的稳定顺序占一行:child 使用 `<id> [<status>] — <label>`(`running` 表示逻辑会话存活,`complete` 表示只存在于持久化存储中且可由 `send_message` 恢复),无法读取的候选使用 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`),空结果使用 `(no subagents)`。Diagnostic 绝不暴露描述符内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
随 parent 的直接可继续 child 数量线性增长;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;每个结果都位于可复用请求前缀之后。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 在该轮次的工作会落入持久化子 agent Session,按其 subagent id 读取,既不会回传,也不会通过本工具收集。
|
||||
- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose 或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。
|
||||
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-subagent-control",
|
||||
"description": "Globally named send_message tool over ctx.subagents continuations",
|
||||
"description": "Globally named send_message and list_agents tools over ctx.subagents continuations",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./list-agents": {
|
||||
"types": "./lib/types/list-agents.d.ts",
|
||||
"default": "./lib/types/list-agents.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -30,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -43,9 +49,10 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* `ctx.subagents.listChildren()`. It is separately loadable from the
|
||||
* root `send_message` plugin because it additionally requires the session
|
||||
* query service — a deployment may use `send_message` without loading session
|
||||
* query, and this plugin catches that misconfiguration at load.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-query'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents', 'sessionQuery']
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and session query.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'List your background subagents: every subagent you started that can receive `send_message`, '
|
||||
+ 'whether it is still working (running) or has finished its current turn (complete — a follow-up '
|
||||
+ 'message starts a new turn on the same conversation). Children that could not be read are '
|
||||
+ 'reported as diagnostics instead of being silently dropped.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, enum: ['child'] },
|
||||
id: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true, enum: ['running', 'complete'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, enum: ['diagnostic'] },
|
||||
id: { type: 'string', required: true },
|
||||
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (_args, entries) => [{
|
||||
type: 'text',
|
||||
text: entries.length === 0
|
||||
? '(no subagents)'
|
||||
: entries.map(entry => entry.kind === 'child'
|
||||
? `${entry.id} [${entry.status}] — ${entry.label}`
|
||||
: `${entry.id} [diagnostic: ${entry.reason}]`).join('\n'),
|
||||
}],
|
||||
},
|
||||
async execute(_args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// Non-agent callers have no session whose children could be listed.
|
||||
throw new Error('list_agents requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
return await ctx.subagents.listChildren(parent.id)
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++calls}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent !== undefined ? { agent: agent as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait until a continuable child released its current Activation. */
|
||||
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(childId)).toBeUndefined()
|
||||
}, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
it('registers list_agents once, globally, with no parameters', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
|
||||
expect(schemas).toHaveLength(1)
|
||||
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props)).toEqual([])
|
||||
expect(schemas[0]!.description).toContain('send_message')
|
||||
})
|
||||
|
||||
it('renders the empty result as (no subagents)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await ctx.sessions.flush(parent.session)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('(no subagents)')
|
||||
})
|
||||
|
||||
it('renders children and diagnostics in array order with the fixed text forms', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'real child',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
// Pin the render deterministically past the service: the tool is a thin
|
||||
// adapter, so its fixed text forms are what this test pins.
|
||||
const entries: SubagentListEntry[] = [
|
||||
{ kind: 'child', id: started.childId, label: 'real child', status: 'complete' },
|
||||
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
|
||||
]
|
||||
ctx.subagents.listChildren = () => Promise.resolve(entries)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\nbroken-child [diagnostic: corrupt]`,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a real settled child end-to-end with its durable label', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'summarize the doc',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = await callTool(ctx, 'list_agents', {})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('requires a calling agent')
|
||||
})
|
||||
|
||||
it('unregisters with its plugin fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-list-agents')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -82,6 +82,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
@@ -109,6 +110,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'long work',
|
||||
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
@@ -144,6 +146,7 @@ describe('dsh-tool-subagent-control', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first')])
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
||||
signal: testToolSignal,
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// there, so this call neither waits for nor collects a result.
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: config.provider,
|
||||
label: args.description,
|
||||
request,
|
||||
signal: exec.signal,
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Generated
+9
-3
@@ -4882,6 +4882,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query
|
||||
'@deepseek-ai/dsh-tasks':
|
||||
specifier: workspace:^
|
||||
version: link:../../tasks/tasks
|
||||
@@ -5187,15 +5190,18 @@ importers:
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence-jsonl
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query
|
||||
'@deepseek-ai/dsh-session-query-sqlite':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query-sqlite
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../subagent
|
||||
'@deepseek-ai/dsh-subagent-spawn':
|
||||
specifier: workspace:^
|
||||
version: link:../subagent-spawn
|
||||
'@deepseek-ai/dsh-tasks':
|
||||
specifier: workspace:^
|
||||
version: link:../../tasks/tasks
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
@@ -166,6 +166,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentRun: 'subagent.md',
|
||||
SubagentService: 'subagent.md',
|
||||
|
||||
@@ -29,6 +29,7 @@ import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
@@ -386,16 +387,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.subagents'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionQuery (list_agents only)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
|
||||
await ctx.plugin(ToolSubagentControl)
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once.',
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"],
|
||||
"@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"],
|
||||
"@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"],
|
||||
"@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"],
|
||||
"@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"],
|
||||
"@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"],
|
||||
"@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"],
|
||||
|
||||
Reference in New Issue
Block a user