feat(subagent): let ancestors interrupt descendants

interrupt_agent(agent_id) passes the calling agent as the ancestor
authority for ctx.subagents.interrupt(); the service verifies live
registry identity and recorded lineage, so a direct child or deeper
descendant stops with the same generic parameter while send_message keeps
its exact-direct-parent authority.

Discovery: list_agents gains an optional scope. descendants walks the new
SubagentService.listDescendants() — one lineage trace flattened in stable
pre-order across ordinary and one-shot intermediates, each entry carrying
its verified parentId and depth — and every status now comes from the
live Agent registry (running/idle/complete).

Refs #1535
This commit is contained in:
Hypatia May
2026-08-06 13:46:55 +08:00
committed by Tianyi Cui
parent 4b29f9ca7a
commit d769d3cbb7
41 changed files with 1325 additions and 162 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1
README.md: 4d9991b720bbbff8862e693b6aaed332f414af0e
README.zh.md: dc1c81fc3313a8dfa277f33ae745702ed5b9d34b

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
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 the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
The optional, globally named `send_message`, `interrupt_agent`, 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 `interrupt_agent` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents` and declares `subagents` plus `agents` as load-time dependencies. Its catalog reads additionally require the session store and projection registry at call time, but no query service. A deployment can keep the root tools while omitting the list tool. No tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
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. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. 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 projects `ctx.subagents.listChildren()` to continuable children without a cursor. The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
`interrupt_agent(agent_id)` passes `exec.agent` as the exact live ancestor authority for `ctx.subagents.interrupt()`: the target may be a direct child or a deeper descendant, and the service — never this tool — verifies the caller against the target Activation's recorded lineage. Only the target's current turn stops (`keepInbox`): queued messages stay parked until a later `send_message`, published descendants keep running, and the child stays available for follow-ups. The call returns as soon as the stop request is accepted, without waiting for target quiescence; an absent or already-settled target is an accepted no-op, while self, sibling, stale, and non-ancestor callers become errored results.
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), `complete` (storage only). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
## Model Experience
@@ -14,7 +16,7 @@ The tool performs no lifecycle routing — residency and cold resume belong to t
#### What the model sees
The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered.
The generated [schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `send_message` takes `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered; `interrupt_agent` takes `agent_id`, describing that only the current turn stops, queued messages park, descendants keep running, and acceptance precedes the actual stop; `list_agents` takes the optional `scope` enum.
#### Token effect
@@ -24,6 +26,20 @@ Fixed schema cost per parent request.
Prefix-stable; the schema does not change at runtime.
### Interrupt result
#### What the model sees
`interrupt requested for agent <agent_id>` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; an absent or settled target still renders the acceptance line.
#### Token effect
One short acknowledgement per call; the interrupted turn's abort is visible only in the child's own transcript.
#### KV Cache effect
Append-only; each result follows the reusable request prefix.
### Delivery result
#### What the model sees
@@ -42,11 +58,11 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
One line per continuable child in the trace's stable order: `<id> [<status>] — <label>` (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `complete` = storage only; a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. 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.
Grows linearly with the listed continuable children — the whole tree under the `descendants` scope; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
#### KV Cache effect
@@ -56,5 +72,5 @@ Append-only; each result follows the reusable request prefix.
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work lands in the durable child Session and is never collected through this tool. A child granted `report` may send selected content back separately, but that message is not this call's result.
- **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.
- **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. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
- **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.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
可选的全局具名 `send_message``list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message`省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
可选的全局具名 `send_message``interrupt_agent``list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message``interrupt_agent`,且只要求 `subagents`可单独加载的 `./list-agents` 插件注册 `list_agents`,并将 `subagents``agents` 声明为加载时依赖。其目录读取在调用时还要求会话存储与投影注册表,但不要求任何查询服务。部署可保留根插件工具并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript文本记录才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
`list_agents` 不接受参数,会从调用它的 agent 推导 parent id并且不使用 cursor`ctx.subagents.listChildren()` 的结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent以供 UI 等消费方使用;但这些条目无法接受 `send_message`因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责
`interrupt_agent(agent_id)``exec.agent` 作为 `ctx.subagents.interrupt()` 的确切在线 ancestor 授权传入:目标可以是直接 child 或更深的后代,由服务——而不是本工具——依据目标 Activation 记录的 lineage 校验调用方。只有目标的当前轮次会停止(`keepInbox`):已排队的消息保持暂停直到之后的 `send_message`已发布的后代继续运行child 也仍可接受后续消息。调用在停止请求被接受后立即返回,不等待目标静止;目标不存在或已结算是被接受的 no-op而 self、sibling、过期与非 ancestor 调用方会成为出错结果
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id并且不使用 cursor将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()``descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id可能指向输出中省略的普通会话。对于调用本工具的 agent只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent`complete`(仅存于存储)。服务结果还包含由会话支撑的一次性 subagent以供 UI 等消费方使用;但这些条目无法接受 `send_message`因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
## 模型体验
@@ -14,7 +16,7 @@
#### 模型看到的内容
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id``message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达。
已生成的 [schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control)`send_message` 包含 `subagent_id``message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达`interrupt_agent` 包含 `agent_id`,说明只有当前轮次会停止、已排队消息保持暂停、后代继续运行,以及接受先于实际停止;`list_agents` 包含可选的 `scope` 枚举
#### Token 影响
@@ -24,6 +26,20 @@
前缀保持稳定schema 不会在运行时改变。
### 中断结果
#### 模型看到的内容
接受时返回 `interrupt requested for agent <agent_id>`。未授权的调用方——self、sibling、过期或非 ancestor——会成为指明拒绝原因的出错结果目标不存在或已结算仍渲染接受行。
#### Token 影响
每次调用产生一条简短确认消息;被中断轮次的中止只在 child 自己的 transcript 中可见。
#### KV Cache 影响
仅追加;每个结果都位于可复用请求前缀之后。
### 投递结果
#### 模型看到的内容
@@ -42,11 +58,11 @@
#### 模型看到的内容
追踪结果的稳定顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>``running` 表示逻辑会话存活,`complete` 表示仅存在于持久化存储中,可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`)。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>``running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`complete` 表示仅存于存储;处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
#### Token 影响
parent 的直接可继续 child 数量线性增长;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
所列可继续 child 数量线性增长——`descendants` scope 下为整棵树;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
#### KV Cache 影响
@@ -56,5 +72,5 @@
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
- **不对当前轮次进行 steering中途引导**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
- **列表是快照,而非投递承诺**它可能与发布、dispose资源释放或后续消息发生竞态另一个进程也可能激活当前进程报告为 `complete` 的 child跨进程准确性需要共享租约。
- **列表是快照,而非投递承诺**它可能与发布、dispose资源释放或后续消息发生竞态另一个进程也可能激活当前进程报告为 `complete` 的 child跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。

View File

@@ -1,9 +1,11 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagents.followup()`. It performs no lifecycle routing of its own —
* residency and cold resume belong to the subagent service — and it lives apart
* from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple
* delegation tools share one control tool.
* The globally named `send_message` and `interrupt_agent` tools: thin
* model-facing adapters over `ctx.subagents.followup()` and
* `ctx.subagents.interrupt()`. They perform no lifecycle routing of their own —
* residency, cold resume, and interrupt authorization belong to the subagent
* service — and they live apart from the provider-bound
* `@deepseek-ai/dsh-tool-subagent` instances so multiple delegation tools share
* one control surface.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
@@ -17,7 +19,7 @@ export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagents']
/**
* Register the `send_message` tool.
* Register the `send_message` and `interrupt_agent` tools.
* @param ctx - context carrying the tool registry and subagent service.
*/
export function apply(ctx: Context): void {
@@ -73,4 +75,46 @@ export function apply(ctx: Context): void {
return { messageId }
},
}))
ctx.tools.register(defineTool({
name: 'interrupt_agent',
description:
'Request cancellation of a background agent\'s current turn by its agent id. The target may be your '
+ 'direct child or a deeper agent created under you. Only the current turn stops: messages already '
+ 'queued for the agent stay parked until a later send_message, agents it started keep running, and '
+ 'the agent itself stays available for follow-ups. This call returns as soon as the stop request is '
+ 'accepted, so the target may keep running briefly; interrupting an agent that already finished is '
+ 'an accepted no-op.',
parameters: {
agent_id: {
type: 'string',
required: true,
description: 'The agent id of the running agent to interrupt.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
accepted: { type: 'boolean', required: true },
},
},
render: (args, _value) => [{
type: 'text',
text: `interrupt requested for agent ${args.agent_id}`,
}],
},
execute(args, exec) {
const caller = exec.agent
if (!caller) {
// Ancestor authority requires an exact live calling agent.
throw new Error('interrupt_agent requires a calling agent (exec.agent was undefined)')
}
// The service authorizes the exact live caller against the target's
// recorded lineage; the tool adds no authority of its own.
ctx.subagents.interrupt(SessionId(args.agent_id), { kind: 'ancestor', agent: caller })
return Promise.resolve({ accepted: true })
},
}))
}

View File

@@ -1,46 +1,95 @@
/**
* The globally named `list_agents` tool: a thin model-facing adapter over
* the continuable projection of `ctx.subagents.listChildren()`. It stays
* separately loadable from the root `send_message` plugin so a deployment
* can register `send_message` without exposing the list tool.
* the continuable projection of `ctx.subagents.listChildren()` and, for the
* `descendants` scope, `ctx.subagents.listDescendants()`. It stays separately
* loadable from the root `send_message` plugin so a deployment can register
* continuation delivery without exposing discovery.
* @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-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentDescendantListEntry, SubagentListEntry } from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent-list-agents'
export const inject = ['tools', 'subagents']
export const inject = ['tools', 'subagents', 'agents']
type ListAgentsEntry =
| {
readonly kind: 'child'
readonly id: string
readonly label: string
readonly status: 'running' | 'complete'
readonly status: 'running' | 'idle' | 'complete'
readonly parent?: string
readonly depth?: number
}
| {
readonly kind: 'diagnostic'
readonly id: string
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
readonly parent?: string
readonly depth?: number
}
/**
* Refine one candidate's status through the live Agent registry: `running`
* for an active driver, `idle` for a resident Agent between turns (possibly
* waiting on agents it started), and `complete` when no live Agent remains.
*/
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
const agent = agents.get(id)
if (agent === undefined) return 'complete'
return agent.status === 'running' ? 'running' : 'idle'
}
/** Project one service row into the model-facing entry, or omit a one-shot child. */
function project(
agents: { get(id: SessionId): Agent | undefined },
entry: SubagentListEntry,
position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
): ListAgentsEntry | undefined {
const at = position === undefined ? {} : { parent: position.parentId as string, depth: position.depth }
if (entry.kind === 'diagnostic') {
return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
}
// One-shot children cannot be continued by send_message, so the model
// never selects them; discovery still traversed them for descendants.
if (entry.mode !== 'continuable') return undefined
return {
kind: 'child',
id: entry.id,
label: entry.label,
status: statusOf(agents, entry.id),
...at,
}
}
/**
* Register the `list_agents` tool.
* @param ctx - context carrying the tool registry and subagent service.
* @param ctx - context carrying the tool registry, subagent service, and live Agent registry.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'list_agents',
description:
'List your continuable background subagents by durable id and label. Status is a snapshot of the stored '
+ 'record: running means the subagent session is currently live in this process, complete means '
+ 'it exists only in storage and a `send_message` starts a new turn on the same conversation. '
+ 'The snapshot is not a delivery promise — `send_message` performs the authoritative check and '
+ 'may still fail. Children that could not be read are reported as diagnostics instead of being '
+ 'silently dropped.',
parameters: {},
'List your continuable background subagents by durable id and label. Status comes from the live '
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
+ 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
+ 'candidates for `interrupt_agent` only.',
parameters: {
scope: {
type: 'string',
enum: ['children', 'descendants'],
description: 'children (default) lists direct children only; descendants walks the complete tree below you.',
},
},
output: {
schema: {
type: 'array',
@@ -53,7 +102,9 @@ export function apply(ctx: Context): void {
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'] },
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
parent: { type: 'string' },
depth: { type: 'number' },
},
},
{
@@ -63,21 +114,31 @@ export function apply(ctx: Context): void {
kind: { type: 'string', required: true, enum: ['diagnostic'] },
id: { type: 'string', required: true },
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
parent: { type: 'string' },
depth: { type: 'number' },
},
},
],
},
},
render: (_args, entries) => [{
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'),
: entries.map((entry) => {
// A descendants row always carries its position; children rows
// never render it. String() spans the schema-optional shape
// without a dead fallback branch.
const at = args.scope === 'descendants'
? ` parent=${String(entry.parent)} depth=${String(entry.depth)}`
: ''
return entry.kind === 'child'
? `${entry.id} [${entry.status}]${at}${entry.label}`
: `${entry.id} [diagnostic: ${entry.reason}]${at}`
}).join('\n'),
}],
},
async execute(_args, exec) {
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session whose children could be listed.
@@ -85,21 +146,16 @@ export function apply(ctx: Context): void {
}
// The registry drains started tool bodies, so the scan must observe the
// call's signal rather than finish a slow catalog after cancellation.
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
const visible: ListAgentsEntry[] = []
for (const entry of entries) {
if (entry.kind === 'diagnostic') {
visible.push(entry)
} else if (entry.mode === 'continuable') {
visible.push({
kind: 'child',
id: entry.id,
label: entry.label,
status: entry.activity === 'running' ? 'running' : 'complete',
})
}
if (args.scope === 'descendants') {
const entries = await ctx.subagents.listDescendants(parent.id, exec.signal)
return entries
.map(entry => project(ctx.agents, entry, entry))
.filter(entry => entry !== undefined)
}
return visible
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
return entries
.map(entry => project(ctx.agents, entry))
.filter(entry => entry !== undefined)
},
}))
}

View File

@@ -12,9 +12,37 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
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 type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/list-agents.ts'
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<undefined>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const testToolSignal = new AbortController().signal
const roots: string[] = []
@@ -22,7 +50,7 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
async function setupWith(adapter: MockAdapter | GatedAdapter) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
@@ -33,9 +61,13 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
return { ctx, parent, adapter }
}
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
return setupWith(new MockAdapter(script))
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -67,13 +99,19 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}
describe('dsh-tool-subagent-control/list-agents', () => {
it('registers list_agents once, globally, with no parameters', async () => {
it('registers list_agents once, globally, with only the optional scope parameter', 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([])
const parameters = schemas[0]!.parameters as {
properties?: Record<string, { enum?: string[] }>
required?: string[]
}
expect(Object.keys(parameters.properties ?? {})).toEqual(['scope'])
expect(parameters.properties?.scope?.enum).toEqual(['children', 'descendants'])
expect(parameters.required ?? []).toEqual([])
expect(schemas[0]!.description).toContain('send_message')
expect(schemas[0]!.description).toContain('interrupt_agent')
})
it('renders the empty result as (no subagents)', async () => {
@@ -84,7 +122,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
expect(text(result)).toBe('(no subagents)')
})
it('renders children and diagnostics in array order with the fixed text forms', async () => {
it('renders children and diagnostics in array order with registry-derived statuses', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
@@ -94,7 +132,8 @@ describe('dsh-tool-subagent-control/list-agents', () => {
})
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.
// adapter, so its fixed text forms are what this test pins. Status comes
// from the live Agent registry, stubbed per candidate id.
const entries: SubagentListEntry[] = [
{
kind: 'child',
@@ -120,14 +159,28 @@ describe('dsh-tool-subagent-control/list-agents', () => {
activity: 'running',
hasChildren: true,
},
{
kind: 'child',
id: SessionId('waiting-child'),
label: 'waiting on descendants',
mode: 'continuable',
activity: 'running',
hasChildren: true,
},
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
]
ctx.subagents.listChildren = () => Promise.resolve(entries)
const agents = new Map<string, { status: 'running' | 'idle' }>([
['running-child', { status: 'running' }],
['waiting-child', { status: 'idle' }],
])
vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [complete] — real child\n`
+ 'running-child [running] — still working\n'
+ 'waiting-child [idle] — waiting on descendants\n'
+ 'broken-child [diagnostic: corrupt]',
)
})
@@ -186,7 +239,101 @@ describe('dsh-tool-subagent-control/list-agents', () => {
it('has the namespace-plugin export shape', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-list-agents')
expect(tool.inject).toEqual(['tools', 'subagents'])
expect(tool.inject).toEqual(['tools', 'subagents', 'agents'])
expect(typeof tool.apply).toBe('function')
})
it('walks the complete descendant tree in pre-order with parent and depth annotations', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'waiting branch',
request: { prompt: [{ type: 'text', text: 'branch work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'nested leaf',
request: { prompt: [{ type: 'text', text: 'leaf work' }], parent: child },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
// The branch finishes its own turn but stays resident waiting on the
// grandchild it owns: the live-registry `idle` status.
releaseChild.resolve(undefined)
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)?.status).toBe('idle')
}, { timeout: 5_000 })
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [idle] parent=${parent.id} depth=1 — waiting branch\n`
+ `${grandchild.childId} [running] parent=${started.childId} depth=2 — nested leaf`,
)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('omits one-shot intermediates from descendants output while surfacing what they own', async () => {
const { ctx, parent } = await setup([])
// Deterministic service rows: a one-shot intermediate owning a continuable
// leaf, plus a positioned diagnostic. The tool filters only the one-shot.
ctx.subagents.listDescendants = () => Promise.resolve([
{
kind: 'child',
id: SessionId('one-shot-mid'),
label: 'one-shot intermediate',
mode: 'one-shot',
activity: 'inactive',
hasChildren: true,
parentId: parent.id,
depth: 1,
},
{
kind: 'child',
id: SessionId('deep-leaf'),
label: 'deep leaf',
mode: 'continuable',
activity: 'inactive',
hasChildren: false,
parentId: SessionId('one-shot-mid'),
depth: 2,
},
{
kind: 'diagnostic',
id: SessionId('broken-node'),
reason: 'unavailable',
parentId: parent.id,
depth: 1,
},
])
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
'deep-leaf [complete] parent=one-shot-mid depth=2 — deep leaf\n'
+ `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
)
})
it('forwards the tool cancellation signal to descendant enumeration', async () => {
const { ctx, parent } = await setup([])
const signal = new AbortController().signal
const listDescendants = vi.spyOn(ctx.subagents, 'listDescendants').mockResolvedValue([])
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent, signal)
expect(result.isError).toBe(false)
expect(listDescendants).toHaveBeenCalledWith(parent.id, signal)
})
})

View File

@@ -11,9 +11,37 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<undefined>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const testToolSignal = new AbortController().signal
const roots: string[] = []
@@ -21,7 +49,7 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
async function setupWith(adapter: MockAdapter | GatedAdapter) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
@@ -32,12 +60,15 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(tool)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent, adapter }
}
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
return setupWith(new MockAdapter(script))
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
@@ -177,8 +208,10 @@ describe('dsh-tool-subagent-control', () => {
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
})
it('has the namespace-plugin export shape (no stray default)', () => {
@@ -188,3 +221,168 @@ describe('dsh-tool-subagent-control', () => {
expect(typeof tool.apply).toBe('function')
})
})
describe('dsh-tool-subagent-control interrupt_agent', () => {
it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props)).toEqual(['agent_id'])
expect(schemas[0]!.description).toContain('current turn')
expect(schemas[0]!.description).toContain('send_message')
})
it('interrupts a running direct child with the parent cause, parking its queue', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('held'), gate: releaseFirst.promise },
{ chunks: textResponse('parked answer') },
{ chunks: textResponse('waking answer') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'long work',
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const queued = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'parked follow-up',
}, parent)
expect(queued.isError).toBe(false)
const cancelSpy = vi.spyOn(child, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseFirst.resolve(undefined)
await child.whenIdle()
// Parked, not resumed: the queued follow-up waits for a waking send.
expect(adapter.requests).toHaveLength(1)
expect(child.inbox.nextTurn).toHaveLength(1)
const waking = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'wake up',
}, parent)
expect(waking.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])
})
it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'grandchild',
request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
expect(result.isError).toBe(false)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseChild.resolve(undefined)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('rejects self, sibling, and unrelated callers without touching the target', async () => {
const releaseA = Promise.withResolvers<undefined>()
const releaseB = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('a'), gate: releaseA.promise },
{ chunks: textResponse('b'), gate: releaseB.promise },
])
const { ctx, parent } = await setupWith(adapter)
const target = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'target',
request: { prompt: [{ type: 'text', text: 'a' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const sibling = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'sibling',
request: { prompt: [{ type: 'text', text: 'b' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const targetAgent = ctx.agents.get(target.childId)!
const siblingAgent = ctx.agents.get(sibling.childId)!
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
const cancelSpy = vi.spyOn(targetAgent, 'cancel')
const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
expect(self.isError).toBe(true)
expect(text(self)).toContain('cannot interrupt itself')
const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
expect(fromSibling.isError).toBe(true)
expect(text(fromSibling)).toContain('not a live descendant')
const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
expect(fromStranger.isError).toBe(true)
expect(text(fromStranger)).toContain('not a live descendant')
expect(cancelSpy).not.toHaveBeenCalled()
releaseA.resolve(undefined)
releaseB.resolve(undefined)
await waitNoActivation(ctx, target.childId)
await waitNoActivation(ctx, sibling.childId)
})
it('accepts an absent target as a no-op without cold-resuming it', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'settled child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(settled.isError).toBe(false)
expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
expect(unknown.isError).toBe(false)
// No cold resume: the settled target never rematerialized.
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
})