feat(subagent): add explicit child reports
This commit is contained in:
@@ -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/README.md
|
||||
README.md: e6e83866e04185ccb1f25187f450ea0e0e549128
|
||||
README.zh.md: 9a7ad5c37ce7d09e4f9f4d21c49175506c024f9b
|
||||
README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211
|
||||
README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326
|
||||
|
||||
@@ -13,7 +13,8 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) |
|
||||
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age
|
||||
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) |
|
||||
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) |
|
||||
|
||||
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
|
||||
|
||||
|
||||
@@ -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/subagent/README.md
|
||||
README.md: 5b7c0376367a942d91700739ad445cf2b7a4455a
|
||||
README.zh.md: 30c5e0b501d0cf3cac749a788bb92a0353b466e9
|
||||
README.md: 4776f45a2f4ba881c2bb8414876100dc84adc86b
|
||||
README.zh.md: a40a12a4b386c91409711b8459a6c3b1f3f37cd0
|
||||
|
||||
@@ -16,6 +16,7 @@ The family separates the stable interface from implementations and model-facing
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. |
|
||||
|
||||
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
|
||||
|
||||
@@ -31,6 +32,8 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. |
|
||||
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
|
||||
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
|
||||
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
|
||||
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
|
||||
@@ -87,6 +90,10 @@ Run events are scoped to the delegating parent. Every listener is independently
|
||||
|
||||
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
@@ -95,7 +102,7 @@ Continuable Activations await a best-effort final session flush without treating
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-subagent` and `dsh-tool-subagent-control`, which render provider-specific schemas and foreground, background, or follow-up results while child working context remains child-only.
|
||||
Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -104,9 +111,9 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
|
||||
- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn.
|
||||
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
|
||||
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state.
|
||||
- **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn.
|
||||
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
|
||||
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
|
||||
- **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt.
|
||||
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.
|
||||
|
||||
@@ -16,6 +16,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 |
|
||||
|
||||
多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。
|
||||
|
||||
@@ -31,6 +32,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
|
||||
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
|
||||
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
|
||||
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
|
||||
@@ -87,6 +90,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
@@ -95,7 +102,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。
|
||||
通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -104,9 +111,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
|
||||
- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。
|
||||
- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
|
||||
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。
|
||||
- **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。
|
||||
- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
|
||||
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。
|
||||
- **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。
|
||||
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。
|
||||
|
||||
196
packages/subagent/subagent/src/activation-setup-registry.ts
Normal file
196
packages/subagent/subagent/src/activation-setup-registry.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Internal registry of deployment capabilities composed into every continuable
|
||||
* child's unpublished creation context.
|
||||
*
|
||||
* A contribution grants a child-scoped capability without teaching the
|
||||
* continuation manager which capabilities exist. The manager owns residency;
|
||||
* this registry owns the join between plugin lifetime, unpublished setup, and
|
||||
* Activation disposal, so no installation outlives either owner and no removed
|
||||
* contribution can be installed after revocation reports completion.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/activation-setup-registry
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SubagentError } from './error.ts'
|
||||
|
||||
/**
|
||||
* One deployment capability installed into a continuable child's unpublished
|
||||
* creation context. It composes synchronously before publication and returns
|
||||
* the disposer for exactly that installation.
|
||||
* @param childCtx - the child's unpublished scoped context.
|
||||
* @returns the disposer revoking this installation.
|
||||
*/
|
||||
export type ContinuableSetupContribution = (childCtx: Context) => () => void
|
||||
|
||||
/** One contribution's live registration. */
|
||||
interface Registration {
|
||||
readonly contribution: ContinuableSetupContribution
|
||||
removed: boolean
|
||||
readonly installations: Set<Installation>
|
||||
}
|
||||
|
||||
/** One contribution installed into one child context. */
|
||||
interface Installation {
|
||||
readonly registration: Registration
|
||||
readonly childCtx: Context
|
||||
readonly dispose: () => void
|
||||
released: boolean
|
||||
/** Present until the child reaches residency. */
|
||||
transaction: TransactionState | undefined
|
||||
}
|
||||
|
||||
/** One child's provisioning batch. */
|
||||
interface TransactionState {
|
||||
readonly installations: Installation[]
|
||||
invalidated: boolean
|
||||
}
|
||||
|
||||
/** Package-private setup transaction consumed by the continuation manager. */
|
||||
export interface ActivationSetupTransaction {
|
||||
/**
|
||||
* Reject a batch invalidated by revocation before publication.
|
||||
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
|
||||
*/
|
||||
assertIntact(): void
|
||||
/** Promote this batch to resident installations. */
|
||||
commit(): void
|
||||
}
|
||||
|
||||
/** Re-read mutable removal state after a contribution may have revoked itself. */
|
||||
function isRemoved(registration: Registration): boolean {
|
||||
return registration.removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns continuable-child setup registrations, installations, rollback, child
|
||||
* cleanup, and immediate live revocation.
|
||||
*/
|
||||
export class SubagentActivationSetupRegistry {
|
||||
/** Live contributions in installation order. */
|
||||
private readonly registrations = new Set<Registration>()
|
||||
/** Child context to its live installations. */
|
||||
private readonly byChild = new Map<Context, Set<Installation>>()
|
||||
|
||||
/**
|
||||
* Register one contribution.
|
||||
* @param contribution - synchronous child-scope installer.
|
||||
* @returns an idempotent registration undo.
|
||||
* @throws after attempting every installation when any disposer fails.
|
||||
*/
|
||||
register(contribution: ContinuableSetupContribution): () => void {
|
||||
const registration: Registration = { contribution, removed: false, installations: new Set() }
|
||||
this.registrations.add(registration)
|
||||
return () => {
|
||||
if (registration.removed) return
|
||||
// Close before disposal so a snapshotted apply() cannot install after
|
||||
// revocation reports completion.
|
||||
registration.removed = true
|
||||
this.registrations.delete(registration)
|
||||
this.releaseAll([...registration.installations], 'contribution removal')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install every live contribution into one unpublished child context.
|
||||
* @param childCtx - the child's unpublished scoped context.
|
||||
* @returns the provisioning transaction.
|
||||
*/
|
||||
apply(childCtx: Context): ActivationSetupTransaction {
|
||||
const state: TransactionState = { installations: [], invalidated: false }
|
||||
try {
|
||||
for (const registration of [...this.registrations]) {
|
||||
/* v8 ignore next -- only a synchronous re-entrant revocation of an
|
||||
* already-snapshotted registration reaches this guard. */
|
||||
if (registration.removed) continue
|
||||
const installation: Installation = {
|
||||
registration,
|
||||
childCtx,
|
||||
dispose: registration.contribution(childCtx),
|
||||
released: false,
|
||||
transaction: state,
|
||||
}
|
||||
registration.installations.add(installation)
|
||||
state.installations.push(installation)
|
||||
let indexed = this.byChild.get(childCtx)
|
||||
if (indexed === undefined) {
|
||||
indexed = new Set()
|
||||
this.byChild.set(childCtx, indexed)
|
||||
}
|
||||
indexed.add(installation)
|
||||
// An installer may revoke itself before its installation record exists.
|
||||
// Dispose that escaped record and invalidate the provisioning batch.
|
||||
if (isRemoved(registration)) this.release(installation)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Keep the installer failure authoritative, but attempt every rollback.
|
||||
try {
|
||||
this.releaseAll([...state.installations], 'setup rollback')
|
||||
} catch (releaseFailure: unknown) {
|
||||
/* v8 ignore next -- requires independent installer and rollback faults. */
|
||||
void releaseFailure
|
||||
}
|
||||
throw error
|
||||
}
|
||||
childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()')
|
||||
return {
|
||||
assertIntact: () => {
|
||||
if (!state.invalidated) return
|
||||
throw new SubagentError(
|
||||
'a continuable-subagent setup contribution was revoked while this child was being built; '
|
||||
+ 'the child was not established',
|
||||
'ACTIVATION_SETUP_REVOKED',
|
||||
)
|
||||
},
|
||||
commit: () => {
|
||||
for (const installation of state.installations) installation.transaction = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Release every remaining installation owned by one disposed child scope. */
|
||||
private releaseChild(childCtx: Context): void {
|
||||
const indexed = this.byChild.get(childCtx) ?? []
|
||||
this.releaseAll([...indexed], 'child scope disposal')
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a batch completely before reporting disposer failures.
|
||||
* @param installations - records to release.
|
||||
* @param during - operation name for diagnostics.
|
||||
*/
|
||||
private releaseAll(installations: readonly Installation[], during: string): void {
|
||||
const failures: unknown[] = []
|
||||
for (const installation of installations) {
|
||||
try {
|
||||
this.release(installation)
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length === 0) return
|
||||
throw new SubagentError(
|
||||
`continuable-subagent setup ${during} failed to release ${failures.length} installation(s): `
|
||||
+ failures.map(failure => errorChain(failure)).join('; '),
|
||||
'ACTIVATION_SETUP_RELEASE_FAILED',
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop one installation from both indices and dispose it exactly once. */
|
||||
private release(installation: Installation): void {
|
||||
if (installation.released) return
|
||||
installation.released = true
|
||||
installation.registration.installations.delete(installation)
|
||||
const indexed = this.byChild.get(installation.childCtx)
|
||||
/* v8 ignore next 4 -- every live installation is indexed until this method removes it. */
|
||||
if (indexed !== undefined) {
|
||||
indexed.delete(installation)
|
||||
if (indexed.size === 0) this.byChild.delete(installation.childCtx)
|
||||
}
|
||||
if (installation.transaction !== undefined) installation.transaction.invalidated = true
|
||||
installation.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export default SubagentActivationSetupRegistry
|
||||
@@ -41,6 +41,8 @@ import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver } from './lifecycle.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
import type { ActivationSetupTransaction } from './activation-setup-registry.ts'
|
||||
|
||||
/** Attribution for a model coordinator's follow-up to one of its children. */
|
||||
export interface CoordinatorMessageSource {
|
||||
@@ -49,12 +51,31 @@ export interface CoordinatorMessageSource {
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
/** Durable attribution for a continuable child's explicit parent report. */
|
||||
export interface SubagentReportMessageSource {
|
||||
readonly kind: 'subagent-report'
|
||||
/** Session id of the reporting child. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
coordinator: CoordinatorMessageSource
|
||||
'subagent-report': SubagentReportMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment scheduling policy for accepted child reports. */
|
||||
export type SubagentReportDelivery = 'quiet' | 'wakeup'
|
||||
|
||||
/** Options for one continuable child's report to its direct parent. */
|
||||
export interface SubagentReportOptions {
|
||||
/** Already-resolved parent scheduling policy. */
|
||||
readonly delivery: SubagentReportDelivery
|
||||
/** Caller cancellation, owning authorization and admission until acceptance. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** What a caller asks for when starting a continuable background child. */
|
||||
export interface ContinuableStartSpec {
|
||||
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
|
||||
@@ -252,6 +273,7 @@ export class SubagentContinuationManager {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly host: ContinuationHost,
|
||||
private readonly setupRegistry: SubagentActivationSetupRegistry,
|
||||
) {
|
||||
// Ordinary Cordis owner effects unwind in reverse registration order, which
|
||||
// cannot express the dynamic child graph. Register the private scope's
|
||||
@@ -386,6 +408,113 @@ export class SubagentContinuationManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver explicitly selected content from one resident continuable child to
|
||||
* its durable direct parent. Sender authorization, parent resolution, and
|
||||
* send acceptance share one no-await span. Reporting neither concludes the
|
||||
* child's turn nor changes its Activation lifetime.
|
||||
* @param child - exact live reporting child; this is the authority credential.
|
||||
* @param content - selected model-facing content.
|
||||
* @param options - scheduling policy and pre-acceptance cancellation.
|
||||
* @returns the stable identity of the message accepted by the parent.
|
||||
* @throws {SubagentError} when the sender is unauthorized, the parent is not
|
||||
* live, or continuation admission is closing.
|
||||
*/
|
||||
// oxlint-disable-next-line typescript/require-await -- keep rejection semantics without yielding during admission
|
||||
async reportFrom(
|
||||
child: Agent,
|
||||
content: ContentBlock[],
|
||||
options: SubagentReportOptions,
|
||||
): Promise<MessageId> {
|
||||
options.signal.throwIfAborted()
|
||||
this.assertAdmitting(child)
|
||||
const activation = this.authorizeReporter(child)
|
||||
const parent = this.resolveReportParent(child)
|
||||
return this.deliverReport(activation, parent, content, options.delivery)
|
||||
}
|
||||
|
||||
/** Authorize only the exact Agent of one resident Activation. */
|
||||
private authorizeReporter(child: Agent): Activation {
|
||||
const activation = this.activations.get(child.id)
|
||||
if (activation === undefined || activation.handle.agent !== child) {
|
||||
throw new SubagentError(
|
||||
`agent "${child.id}" is not a live continuable subagent and cannot report`,
|
||||
'UNAUTHORIZED',
|
||||
)
|
||||
}
|
||||
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can open this
|
||||
* transaction between exact-agent authorization and this no-await cutoff. */
|
||||
if (activation.disposal !== undefined) {
|
||||
throw new SubagentError(
|
||||
`subagent "${child.id}" activation is being disposed; the report was not delivered`,
|
||||
'ACTIVATION_CLOSING',
|
||||
)
|
||||
}
|
||||
return activation
|
||||
}
|
||||
|
||||
/** Resolve the reporting child's live direct parent from durable lineage. */
|
||||
private resolveReportParent(child: Agent): Agent {
|
||||
const parentId = child.session.header.parentSession
|
||||
/* v8 ignore next -- every continuation-managed child has direct-parent metadata. */
|
||||
const parent = parentId === undefined ? undefined : this.ctx.agents.get(parentId)
|
||||
if (parent === undefined) {
|
||||
throw new SubagentError(
|
||||
'direct parent is not live; report was not delivered',
|
||||
'PARENT_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
/** Deliver one framed report through the selected parent scheduling preset. */
|
||||
private deliverReport(
|
||||
activation: Activation,
|
||||
parent: Agent,
|
||||
content: ContentBlock[],
|
||||
delivery: SubagentReportDelivery,
|
||||
): MessageId {
|
||||
const message = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Background subagent ${activation.childId} reported:` },
|
||||
...content,
|
||||
],
|
||||
source: {
|
||||
kind: 'subagent-report' as const,
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (delivery === 'wakeup'
|
||||
&& parentActivation !== undefined
|
||||
&& parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, () => {
|
||||
this.sendReport(parent, message, delivery)
|
||||
})
|
||||
} else {
|
||||
this.sendReport(parent, message, delivery)
|
||||
}
|
||||
return message.id
|
||||
}
|
||||
|
||||
/** Send one report while translating only the parent's own rejection. */
|
||||
private sendReport(
|
||||
parent: Agent,
|
||||
message: ReturnType<typeof createUserMessage>,
|
||||
delivery: SubagentReportDelivery,
|
||||
): void {
|
||||
try {
|
||||
if (delivery === 'wakeup') parent.followup(message)
|
||||
else parent.inject(message)
|
||||
} catch (error: unknown) {
|
||||
throw new SubagentError(
|
||||
'direct parent is not live; report was not delivered',
|
||||
'PARENT_UNAVAILABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close admission, await every already-admitted materialization through
|
||||
* publication or rollback, then dispose the stable live Activation forest
|
||||
@@ -671,7 +800,11 @@ export class SubagentContinuationManager {
|
||||
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
|
||||
// some other owner holds — a duplicate would reject there with rollback.
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
|
||||
let setupTransaction!: ActivationSetupTransaction
|
||||
const setup = (childCtx: Context): void => {
|
||||
applyChildComposition(childCtx, inputs.composition)
|
||||
setupTransaction = this.setupRegistry.apply(childCtx)
|
||||
}
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
const { create } = inputs
|
||||
// Agent creation owns rollback before handle transfer. A rejection leaves
|
||||
@@ -709,6 +842,7 @@ export class SubagentContinuationManager {
|
||||
try {
|
||||
inputs.signal.throwIfAborted()
|
||||
this.assertAdmitting(parent)
|
||||
setupTransaction.assertIntact()
|
||||
this.acquireOwnership(parent, childId)
|
||||
// Every accepted id leaves the inbox exactly once, through dequeue or
|
||||
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
||||
@@ -726,8 +860,10 @@ export class SubagentContinuationManager {
|
||||
for (const item of items) activation.accepted.delete(item.message.id)
|
||||
this.wake(activation)
|
||||
})
|
||||
// Resident: publish the start edge before any turn can run, so observers
|
||||
// see this epoch before its first request.
|
||||
// Resident setup revokes live from here instead of invalidating creation.
|
||||
setupTransaction.commit()
|
||||
// Publish the start edge before any turn can run, so observers see this
|
||||
// epoch before its first request.
|
||||
observer.start(handle.agent)
|
||||
} catch (error: unknown) {
|
||||
// Listener exceptions are contained by the lifecycle emitter; a start
|
||||
@@ -803,19 +939,36 @@ export class SubagentContinuationManager {
|
||||
// establish it before the message can enter the child's inbox.
|
||||
this.acquireOwnership(parent, activation.childId)
|
||||
const message = createUserMessage({ content, source })
|
||||
// `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its
|
||||
// observers must see this Activation as busy before the call begins.
|
||||
activation.accepted.add(message.id)
|
||||
try {
|
||||
return this.admitWaking(activation, message.id, () => {
|
||||
activation.handle.agent.followup(message)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Account one waking send across a resident Activation's settlement window.
|
||||
* @param activation - Activation receiving waking inbox work.
|
||||
* @param messageId - stable identity of the message about to be sent.
|
||||
* @param send - synchronous send that publishes one enqueue occurrence.
|
||||
* @returns the accepted message id.
|
||||
*/
|
||||
private admitWaking(
|
||||
activation: Activation,
|
||||
messageId: MessageId,
|
||||
send: () => void,
|
||||
): MessageId {
|
||||
// `Agent.followup()` publishes inbox events synchronously, so observers must
|
||||
// see this Activation as busy before the call begins.
|
||||
activation.accepted.add(messageId)
|
||||
try {
|
||||
send()
|
||||
} catch (error: unknown) {
|
||||
activation.accepted.delete(message.id)
|
||||
activation.accepted.delete(messageId)
|
||||
throw error
|
||||
}
|
||||
// Accepted waking work keeps this Activation live until whenIdle() observes
|
||||
// the complete waking suffix.
|
||||
this.wake(activation)
|
||||
return message.id
|
||||
return messageId
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,10 @@ import type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
SubagentFollowupOptions,
|
||||
SubagentReportOptions,
|
||||
} from './continuation.ts'
|
||||
import SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
import type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
@@ -107,7 +110,11 @@ export type {
|
||||
ContinuableStartSpec,
|
||||
CoordinatorMessageSource,
|
||||
SubagentFollowupOptions,
|
||||
SubagentReportDelivery,
|
||||
SubagentReportMessageSource,
|
||||
SubagentReportOptions,
|
||||
} from './continuation.ts'
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
@@ -156,6 +163,8 @@ declare module 'cordis' {
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
/** Deployment contributions composed into unpublished continuable children. */
|
||||
private readonly setupRegistry = new SubagentActivationSetupRegistry()
|
||||
/**
|
||||
* The contained lifecycle-edge publisher. Built here because scoped dispatch
|
||||
* keys its carrier by this exact service instance, whose own context filter
|
||||
@@ -170,7 +179,7 @@ export class SubagentService extends Service {
|
||||
const manager = new SubagentContinuationManager(childCtx, {
|
||||
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
|
||||
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
|
||||
})
|
||||
}, this.setupRegistry)
|
||||
this.continuations = manager
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
|
||||
@@ -216,6 +225,41 @@ export class SubagentService extends Service {
|
||||
return this.requireContinuations().followup(parent, childId, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver selected content from one live continuable child to its durable
|
||||
* direct parent. The child is the authority credential; callers cannot name a
|
||||
* recipient. Reporting does not conclude the child's turn or Activation.
|
||||
* @param child - exact live reporting child.
|
||||
* @param content - selected model-facing content.
|
||||
* @param options - parent scheduling and pre-acceptance cancellation.
|
||||
* @returns the stable identity of the parent-accepted message.
|
||||
* @throws when continuation services are unavailable, sender authorization
|
||||
* fails, or the direct parent is not live.
|
||||
*/
|
||||
async reportFrom(
|
||||
child: Agent,
|
||||
content: ContentBlock[],
|
||||
options: SubagentReportOptions,
|
||||
): Promise<MessageId> {
|
||||
return this.requireContinuations().reportFrom(child, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one deployment capability into every continuable child's
|
||||
* unpublished creation context on fresh creation and cold resume. Grants wait
|
||||
* for the next Activation; removing the contribution revokes every resident
|
||||
* installation immediately.
|
||||
* @param contribution - synchronous child-scope installer.
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
registerContinuableSetup(contribution: ContinuableSetupContribution): () => void {
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(
|
||||
() => this.setupRegistry.register(contribution),
|
||||
'subagents.registerContinuableSetup()',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close continuable admission below exact live parent Agents, stop only their
|
||||
* visible descendant Activations synchronously, then await admitted scoped
|
||||
@@ -224,7 +268,7 @@ export class SubagentService extends Service {
|
||||
* remain live.
|
||||
* @param parents - exact host-owned parent Agents entering teardown.
|
||||
* @returns once every retained descendant Activation released its `AgentHandle`.
|
||||
* @throws an aggregate error after all scoped branches settle when any failed.
|
||||
* @throws an aggregate error after all branches settle when any failed.
|
||||
*/
|
||||
async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> {
|
||||
const manager = this.continuations
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts'
|
||||
|
||||
/** A child-like scoped context with observable disposal. */
|
||||
function childContext(): { ctx: Context; close: () => Promise<void> } {
|
||||
const root = new Context()
|
||||
const scope = root.plugin(function child() {})
|
||||
return { ctx: scope.ctx, close: async () => { await scope.dispose() } }
|
||||
}
|
||||
|
||||
describe('SubagentActivationSetupRegistry', () => {
|
||||
it('installs contributions in registration order and commits them', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const order: string[] = []
|
||||
registry.register(() => { order.push('first'); return () => order.push('undo-first') })
|
||||
registry.register(() => { order.push('second'); return () => order.push('undo-second') })
|
||||
const child = childContext()
|
||||
|
||||
const transaction = registry.apply(child.ctx)
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
expect(() => { transaction.assertIntact() }).not.toThrow()
|
||||
transaction.commit()
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('makes repeated removal and converging ownership idempotent', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
remove()
|
||||
remove()
|
||||
await child.close()
|
||||
expect(disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('makes the opposite ownership convergence idempotent', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
await child.close()
|
||||
remove()
|
||||
expect(disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('skips a contribution removed before a child is applied', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const installed: string[] = []
|
||||
const remove = registry.register(() => { installed.push('gone'); return () => {} })
|
||||
registry.register(() => { installed.push('kept'); return () => {} })
|
||||
remove()
|
||||
|
||||
registry.apply(childContext().ctx).commit()
|
||||
expect(installed).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('invalidates a provisioning batch revoked before commit', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const remove = registry.register(() => () => { disposals += 1 })
|
||||
const transaction = registry.apply(childContext().ctx)
|
||||
|
||||
remove()
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
|
||||
})
|
||||
|
||||
it('catches a contribution revoked inside its own installer', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
let disposals = 0
|
||||
const self: { remove?: () => void } = {}
|
||||
self.remove = registry.register(() => {
|
||||
self.remove?.()
|
||||
return () => { disposals += 1 }
|
||||
})
|
||||
|
||||
const transaction = registry.apply(childContext().ctx)
|
||||
expect(disposals).toBe(1)
|
||||
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
|
||||
})
|
||||
|
||||
it('attempts every contribution-removal disposer before reporting failures', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const released: string[] = []
|
||||
let seq = 0
|
||||
const remove = registry.register(() => {
|
||||
const id = `child-${++seq}`
|
||||
return () => {
|
||||
released.push(id)
|
||||
if (id === 'child-1') throw new Error('disposer exploded')
|
||||
}
|
||||
})
|
||||
for (const child of [childContext(), childContext(), childContext()]) {
|
||||
registry.apply(child.ctx).commit()
|
||||
}
|
||||
|
||||
expect(() => { remove() }).toThrow(/failed to release 1 installation\(s\)/)
|
||||
expect(released).toEqual(['child-1', 'child-2', 'child-3'])
|
||||
})
|
||||
|
||||
it('attempts every child-scope disposer before reporting failures', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const released: string[] = []
|
||||
registry.register(() => () => {
|
||||
released.push('a')
|
||||
throw new Error('first disposer exploded')
|
||||
})
|
||||
registry.register(() => () => { released.push('b') })
|
||||
const child = childContext()
|
||||
registry.apply(child.ctx).commit()
|
||||
|
||||
await child.close().catch(() => undefined)
|
||||
expect(released).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('rolls back earlier installations when a later contribution throws', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const undone: string[] = []
|
||||
registry.register(() => () => undone.push('first'))
|
||||
registry.register(() => { throw new Error('boom') })
|
||||
registry.register(() => () => undone.push('third'))
|
||||
|
||||
expect(() => registry.apply(childContext().ctx)).toThrow(/boom/)
|
||||
expect(undone).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('does not dispose twice when revocation precedes setup rollback', () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const disposals: string[] = []
|
||||
const removeFirst = registry.register(() => () => { disposals.push('first') })
|
||||
registry.register(() => {
|
||||
removeFirst()
|
||||
throw new Error('second failed after revoking the first')
|
||||
})
|
||||
|
||||
expect(() => registry.apply(childContext().ctx)).toThrow(/second failed/)
|
||||
expect(disposals).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('does not cross-release independent child scopes', async () => {
|
||||
const registry = new SubagentActivationSetupRegistry()
|
||||
const disposed: string[] = []
|
||||
let seq = 0
|
||||
registry.register(() => {
|
||||
const id = `child-${++seq}`
|
||||
return () => disposed.push(id)
|
||||
})
|
||||
const first = childContext()
|
||||
const second = childContext()
|
||||
registry.apply(first.ctx).commit()
|
||||
registry.apply(second.ctx).commit()
|
||||
|
||||
await first.close()
|
||||
expect(disposed).toEqual(['child-1'])
|
||||
await second.close()
|
||||
expect(disposed).toEqual(['child-1', 'child-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 packages/subagent/tool-subagent-control/README.md
|
||||
README.md: 5babd9a34fd63b0152543eca4467cd3afa3ff927
|
||||
README.zh.md: bfeede644e7ffdd8ba5b01b2b62c2974937133df
|
||||
README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca
|
||||
README.zh.md: b82f59ce89690f449115690354f07d5d18e9bed5
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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 requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. 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 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`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. 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. 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. 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.
|
||||
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.
|
||||
|
||||
@@ -14,7 +14,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 the subagent does not reply, and that a failure means the message was not delivered.
|
||||
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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -32,7 +32,7 @@ Prefix-stable; the schema does not change at runtime.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One short acknowledgement per call; the child's response never returns through this tool, so its output enters parent history only if a caller reads the child transcript and relays it.
|
||||
One short acknowledgement per call; the child's response never returns through this call. A separately granted `report` may append selected content to parent history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -54,7 +54,7 @@ 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.
|
||||
- **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.
|
||||
- **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,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@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 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
本工具不执行生命周期路由:驻留与冷恢复归 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` 负责。
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、子 agent 不会回复,以及失败即表示消息未送达。
|
||||
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用产生一条简短确认消息;子 agent 的响应绝不会通过本工具返回,因此只有当调用方读取子 agent transcript 并转达时,其输出才会进入父级历史。
|
||||
每次调用产生一条简短确认消息;子 agent 的响应绝不会通过本次调用返回。单独授予的 `report` 可以把选定内容追加到父级历史中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
|
||||
仅追加;每个结果都位于可复用请求前缀之后。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 在该轮次的工作会落入持久化子 agent Session,按其 subagent id 读取,既不会回传,也不会通过本工具收集。
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent Session,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
|
||||
- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose 或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。
|
||||
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。
|
||||
|
||||
@@ -26,8 +26,9 @@ export function apply(ctx: Context): void {
|
||||
description:
|
||||
'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.',
|
||||
+ 'finishes, so it cannot redirect work already underway. This call returns no answer from the '
|
||||
+ 'subagent — only confirmation that the message was delivered — so use it to give it more work. A '
|
||||
+ 'failure means the message was NOT delivered.',
|
||||
parameters: {
|
||||
subagent_id: {
|
||||
type: 'string',
|
||||
|
||||
6
packages/subagent/tool-subagent-report/README.i18n.yaml
Normal file
6
packages/subagent/tool-subagent-report/README.i18n.yaml
Normal file
@@ -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 packages/subagent/tool-subagent-report/README.md
|
||||
README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e
|
||||
README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8
|
||||
67
packages/subagent/tool-subagent-report/README.md
Normal file
67
packages/subagent/tool-subagent-report/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# @deepseek-ai/dsh-tool-subagent-report
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
|
||||
|
||||
A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source.
|
||||
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
|
||||
|
||||
Scope-local registration deliberately survives the child's global `toolFilter`, so a delegation allow-list cannot remove the only return channel. A deployment that requires a child with no return channel omits this package.
|
||||
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` into a minted child scope. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schema
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that reporting is explicit and repeatable, reaches only the Agent that started the child, and does not end the turn. It carries no recipient or delivery-mode parameter.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost per continuable-child request, and none in any other Agent's requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable within a child; the schema does not change at runtime. Removing the package revokes the schema from resident children, which changes their next request prefix.
|
||||
|
||||
### Report result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`report accepted by the agent that started you as message <messageId>` on acceptance; the canonical output carries the stable `messageId`. A failure from an unauthorized sender, an unavailable parent, or a closing lifecycle is an errored result. The description says a failed call may still have arrived because a later `tools/post-execute` failure can replace the result after `reportFrom()` accepted the message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: quiet delivery adds it to the parent's next request, while waking delivery makes it the sole ordinary message of one new parent turn.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only in the child. In the parent, the framed report follows existing history and preserves the reusable prefix.
|
||||
|
||||
### Parent-visible report
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One user-role parent message framed as `Background subagent <child-id> reported:` followed by the child's exact `output`, with durable provenance `{ kind: 'subagent-report', senderSessionId: <child-id> }`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The child's complete `output` plus the one-line frame, uncapped by this package.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; the report follows the parent's reusable request prefix. Waking delivery starts an independent parent model request, while quiet delivery does not.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication.
|
||||
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
|
||||
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
|
||||
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` only on that child's next Activation, while removing the package revokes the schema from resident children immediately.
|
||||
- **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later.
|
||||
- **No rate limiting** — `wakeup` mode can amplify model work when nested children report frequently; the deployment owns that choice by selecting the mode.
|
||||
67
packages/subagent/tool-subagent-report/README.zh.md
Normal file
67
packages/subagent/tool-subagent-report/README.zh.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# @deepseek-ai/dsh-tool-subagent-report
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
|
||||
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。
|
||||
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
|
||||
作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。
|
||||
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。
|
||||
|
||||
### 上报结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
接受时返回 `report accepted by the agent that started you as message <messageId>`;规范输出携带稳定的 `messageId`。发送方未授权、父级不可用或生命周期正在关闭时,失败会成为出错的结果。描述中会说明,失败的调用仍可能已经送达,因为 `reportFrom()` 接受消息后,后续 `tools/post-execute` 失败可能替换工具结果。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在子级中仅追加。在父级中,带前缀的报告位于现有历史之后,并保留可复用前缀。
|
||||
|
||||
### 父级可见的报告
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
一条用户角色的父级消息,以 `Background subagent <child-id> reported:` 开头,后接子级准确的 `output`,并带有持久化来源 `{ kind: 'subagent-report', senderSessionId: <child-id> }`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
子级的完整 `output` 加上一行前缀;本包不设上限。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;报告位于父级可复用请求前缀之后。唤醒投递会启动一次独立的父级模型请求,静默投递则不会。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。
|
||||
- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。
|
||||
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。
|
||||
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。
|
||||
- **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。
|
||||
54
packages/subagent/tool-subagent-report/package.json
Normal file
54
packages/subagent/tool-subagent-report/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-subagent-report",
|
||||
"description": "Child-scoped report tool over ctx.subagents continuations",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
94
packages/subagent/tool-subagent-report/src/index.ts
Normal file
94
packages/subagent/tool-subagent-report/src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The child-scoped `report` tool, installed into every continuable in-process
|
||||
* child's unpublished context. Roots, one-shot children, remote providers, and
|
||||
* agentless executions never see the registration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-subagent-report
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-subagent-report'
|
||||
// The contribution registers only through childCtx.tools, but declaring tools
|
||||
// makes Loader ordering fail at load instead of the next child materialization.
|
||||
export const inject = ['subagents', 'tools']
|
||||
|
||||
/** Config: how accepted reports are scheduled on the parent. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Parent scheduling (default `quiet`). `quiet` adds context without waking;
|
||||
* `wakeup` creates one ordinary later parent turn.
|
||||
*/
|
||||
reportDelivery?: SubagentReportDelivery
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Install `report` into one continuable child's scope.
|
||||
* @param childCtx - child-scoped context receiving the tool.
|
||||
* @param ctx - service context used for delivery.
|
||||
* @param delivery - resolved deployment scheduling policy.
|
||||
* @returns disposer for this one registration.
|
||||
*/
|
||||
export function installReportTool(
|
||||
childCtx: Context,
|
||||
ctx: Context,
|
||||
delivery: SubagentReportDelivery,
|
||||
): () => void {
|
||||
return childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this zero or more times for progress, '
|
||||
+ 'findings, or a final answer. Reporting does not end your turn or finish your work, and only your '
|
||||
+ 'direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.',
|
||||
parameters: {
|
||||
output: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Self-contained content for your parent; it does not see your private work.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
messageId: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `report accepted by the agent that started you as message ${value.messageId}`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: args.output }]
|
||||
// Scope-local resolution guarantees an Agent. The service still verifies
|
||||
// its exact live Activation identity at the authority boundary.
|
||||
const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, {
|
||||
delivery,
|
||||
signal: exec.signal,
|
||||
})
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the continuable-child contribution.
|
||||
* @param ctx - context carrying tools and the subagent service.
|
||||
* @param config - deployment scheduling policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const { reportDelivery = 'quiet' } = Config(config)
|
||||
ctx.subagents.registerContinuableSetup(childCtx =>
|
||||
installReportTool(childCtx, ctx, reportDelivery))
|
||||
}
|
||||
30
packages/subagent/tool-subagent-report/src/invariant.ts
Normal file
30
packages/subagent/tool-subagent-report/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-report`.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-report/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-report'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-subagent-report-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this adapter has no independent lifecycle stream;
|
||||
* sender authorization and delivery relations belong to the subagent service.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - context carrying the invariant service.
|
||||
* @returns the registration disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,369 @@
|
||||
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as control from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
const testSignal = new AbortController().signal
|
||||
|
||||
/** Adapter that keeps child Activations resident until released. */
|
||||
class HeldAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
private readonly gate = Promise.withResolvers<undefined>()
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
await this.gate.promise
|
||||
for (const chunk of textResponse('held answer')) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.gate.resolve(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
||||
})
|
||||
|
||||
/** Boot the real continuation graph with optional report installation. */
|
||||
async function setup(options: { load?: boolean; config?: tool.Config } = {}) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-'))
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
const fiber = options.load === false
|
||||
? undefined
|
||||
: await ctx.plugin(tool, options.config ?? { reportDelivery: 'quiet' })
|
||||
const adapter = new HeldAdapter()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
cleanups.push(async () => {
|
||||
adapter.release()
|
||||
await ctx.fiber.dispose()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
return { ctx, parent, adapter, fiber }
|
||||
}
|
||||
|
||||
/** Start and resolve one resident continuable child. */
|
||||
async function startChild(ctx: Context, parent: Agent, prompt = 'child task') {
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: prompt,
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
parent,
|
||||
},
|
||||
signal: testSignal,
|
||||
})
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live as Agent
|
||||
})
|
||||
return { started, child }
|
||||
}
|
||||
|
||||
let calls = 0
|
||||
function callReport(ctx: Context, child: Agent, output: string, signal = testSignal) {
|
||||
return ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId(`report-${++calls}`),
|
||||
name: 'report',
|
||||
arguments: { output },
|
||||
agent: child,
|
||||
})
|
||||
}
|
||||
|
||||
/** Reports durably visible in one Agent's Session. */
|
||||
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
|
||||
return agent.session.events.flatMap((event) => {
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'subagent-report') return []
|
||||
return [{
|
||||
id: event.data.id,
|
||||
text: event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
|
||||
sender: event.data.source.senderSessionId,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function renderedText(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-report', () => {
|
||||
it('registers report only in continuable child scopes', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('report')
|
||||
expect(ctx.tools.schemas(parent).map(schema => schema.name)).not.toContain('report')
|
||||
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const schemas = ctx.tools.schemas(child).filter(schema => schema.name === 'report')
|
||||
expect(schemas).toHaveLength(1)
|
||||
const properties = (schemas[0]?.parameters as { properties: Record<string, unknown> }).properties
|
||||
expect(Object.keys(properties)).toEqual(['output'])
|
||||
})
|
||||
|
||||
it('adds no implicit capability when the package is absent', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect((await callReport(ctx, child, 'missing')).isError).toBe(true)
|
||||
})
|
||||
|
||||
it('does not imply parent controls and survives a global-tool allow-list', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('send_message')
|
||||
await ctx.plugin(control)
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toContain('send_message')
|
||||
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'restricted child',
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: 'restricted child' }],
|
||||
parent,
|
||||
toolFilter: { allow: [] },
|
||||
},
|
||||
signal: testSignal,
|
||||
})
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live as Agent
|
||||
})
|
||||
const names = ctx.tools.schemas(child).map(schema => schema.name)
|
||||
expect(names).toContain('report')
|
||||
expect(names).not.toContain('send_message')
|
||||
})
|
||||
|
||||
it('delivers quiet reports with stable identity and provenance without waking', async () => {
|
||||
const { ctx, parent, adapter } = await setup()
|
||||
const { started, child } = await startChild(ctx, parent)
|
||||
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
|
||||
const enqueues: string[] = []
|
||||
ctx.on('agent/inbox/enqueue', (agent, item) => {
|
||||
if (agent === parent) enqueues.push(item.placement)
|
||||
})
|
||||
|
||||
const result = await callReport(ctx, child, 'CHILD_FINDING')
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('report unexpectedly failed')
|
||||
const messageId = (result.value as { messageId: string }).messageId
|
||||
expect(renderedText(result)).toContain(messageId)
|
||||
expect(reports(parent)).toEqual([{
|
||||
id: messageId,
|
||||
text: `Background subagent ${started.childId} reported:\nCHILD_FINDING`,
|
||||
sender: started.childId,
|
||||
}])
|
||||
expect(enqueues).toEqual([])
|
||||
expect(parent.status).toBe('idle')
|
||||
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(parentRequests)
|
||||
})
|
||||
|
||||
it('queues wakeup reports as one later parent turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const enqueues: string[] = []
|
||||
ctx.on('agent/inbox/enqueue', (agent, item) => {
|
||||
if (agent === parent) enqueues.push(item.placement)
|
||||
})
|
||||
|
||||
const result = await callReport(ctx, child, 'WAKE_UP')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(enqueues).toEqual(['queued'])
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves accepted order across repeated reports', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
|
||||
expect((await callReport(ctx, child, 'FIRST')).isError).toBe(false)
|
||||
expect((await callReport(ctx, child, 'SECOND')).isError).toBe(false)
|
||||
expect(reports(parent).map(report => report.text.split('\n').at(-1))).toEqual(['FIRST', 'SECOND'])
|
||||
})
|
||||
|
||||
it('keeps an accepted report after the child settles', async () => {
|
||||
const { ctx, parent, adapter } = await setup()
|
||||
const { started, child } = await startChild(ctx, parent)
|
||||
expect((await callReport(ctx, child, 'DURABLE_SELECTION')).isError).toBe(false)
|
||||
|
||||
adapter.release()
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
|
||||
expect(reports(parent).map(report => report.text)).toEqual([
|
||||
`Background subagent ${started.childId} reported:\nDURABLE_SELECTION`,
|
||||
])
|
||||
})
|
||||
|
||||
it('routes nested reports exactly one edge upward', async () => {
|
||||
const { ctx, parent, adapter } = await setup()
|
||||
const { child } = await startChild(ctx, parent, 'outer task')
|
||||
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
|
||||
|
||||
expect((await callReport(ctx, grandchild, 'FROM_GRANDCHILD')).isError).toBe(false)
|
||||
expect(reports(parent)).toEqual([])
|
||||
// The intermediate parent's turn is open, so quiet context is staged until
|
||||
// that turn reaches its next safe log boundary.
|
||||
expect(reports(child)).toEqual([])
|
||||
adapter.release()
|
||||
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
|
||||
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
|
||||
expect(reports(child)[0]?.text).toContain('FROM_GRANDCHILD')
|
||||
})
|
||||
|
||||
it('accounts wakeup reports delivered to a resident continuable parent', async () => {
|
||||
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
|
||||
const { child } = await startChild(ctx, parent, 'outer task')
|
||||
const { started: grandchildStart, child: grandchild } = await startChild(ctx, child, 'inner task')
|
||||
|
||||
expect((await callReport(ctx, grandchild, 'WAKE_PARENT_CHILD')).isError).toBe(false)
|
||||
expect(ctx.agents.get(child.id)).toBe(child)
|
||||
|
||||
adapter.release()
|
||||
await vi.waitFor(() => { expect(reports(child)).toHaveLength(1) })
|
||||
expect(reports(child)[0]?.sender).toBe(grandchildStart.childId)
|
||||
expect(reports(child)[0]?.text).toContain('WAKE_PARENT_CHILD')
|
||||
})
|
||||
|
||||
it('normalizes a direct parent send rejection', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
vi.spyOn(parent, 'inject').mockImplementationOnce(() => {
|
||||
throw new Error('parent closed during delivery')
|
||||
})
|
||||
|
||||
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'rejected' }], {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})).rejects.toMatchObject({ code: 'PARENT_UNAVAILABLE' })
|
||||
expect(reports(parent)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects roots, forged same-id senders, absent parents, cancellation, and drain', async () => {
|
||||
const { ctx, parent, adapter } = await setup()
|
||||
await expect(ctx.subagents.reportFrom(parent, [{ type: 'text', text: 'root' }], {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
||||
|
||||
const disposable = await ctx.agents.create({
|
||||
sessionId: SessionId('disposable-parent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const { child } = await startChild(ctx, disposable.agent)
|
||||
const forged = { ...child } as Agent
|
||||
await expect(ctx.subagents.reportFrom(forged, [{ type: 'text', text: 'forged' }], {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
expect((await callReport(ctx, child, 'cancelled', aborted.signal)).isError).toBe(true)
|
||||
|
||||
await disposable.dispose()
|
||||
expect((await callReport(ctx, child, 'orphaned')).isError).toBe(true)
|
||||
|
||||
adapter.release()
|
||||
const draining = ctx.subagents.drainContinuableDescendants([child])
|
||||
await expect(ctx.subagents.reportFrom(child, [{ type: 'text', text: 'draining' }], {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})).rejects.toMatchObject({ code: 'DRAINING' })
|
||||
await draining
|
||||
})
|
||||
|
||||
it('revokes resident installations and defers later grants to the next Activation', async () => {
|
||||
const { ctx, parent, fiber } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
|
||||
|
||||
await fiber?.dispose()
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect((await callReport(ctx, child, 'revoked')).isError).toBe(true)
|
||||
|
||||
const late = await ctx.plugin(tool, { reportDelivery: 'quiet' })
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
await late.dispose()
|
||||
})
|
||||
|
||||
it('rolls back materialization when a setup contribution revokes itself', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const self: { revoke?: () => void } = {}
|
||||
self.revoke = ctx.subagents.registerContinuableSetup((childCtx) => {
|
||||
const dispose = childCtx.tools.register({
|
||||
name: 'racing-report',
|
||||
description: 'racing setup',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'object', properties: {} }, render: () => [] },
|
||||
execute: () => Promise.resolve({}),
|
||||
})
|
||||
self.revoke?.()
|
||||
return dispose
|
||||
})
|
||||
|
||||
await expect(ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'racing child',
|
||||
request: {
|
||||
prompt: [{ type: 'text', text: 'racing child' }],
|
||||
parent,
|
||||
},
|
||||
signal: testSignal,
|
||||
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
|
||||
})
|
||||
|
||||
it('keeps the namespace plugin shape and validates its default', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-report')
|
||||
expect(tool.inject).toEqual(['subagents', 'tools'])
|
||||
expect(tool.Config({}).reportDelivery).toBe('quiet')
|
||||
expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/** Prove report delivery uses ordinary logged user messages. */
|
||||
function userTexts(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-report result independence', () => {
|
||||
it('does not report a final assistant answer automatically or create Tasks', async () => {
|
||||
const { ctx, parent, adapter } = await setup()
|
||||
const { started } = await startChild(ctx, parent)
|
||||
adapter.release()
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeUndefined() })
|
||||
|
||||
expect(reports(parent)).toEqual([])
|
||||
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
30
packages/subagent/tool-subagent-report/tsconfig.json
Normal file
30
packages/subagent/tool-subagent-report/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -246,10 +246,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description + (backgroundEnabled
|
||||
// The return channel is a separately installed capability this package
|
||||
// cannot observe, so this describes only this call's result.
|
||||
? continuable
|
||||
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
|
||||
+ ' you receive its subagent id and it works on its own. It does not report back, so use this'
|
||||
+ ' only for work whose result you do not need returned; `send_message` sends it more work.'
|
||||
+ ' you receive only its subagent id, never its result, and it works on its own. Use this for'
|
||||
+ ' work whose result you do not need returned by this call; `send_message` sends it more work.'
|
||||
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
@@ -267,8 +269,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
run_in_background: {
|
||||
type: 'boolean' as const,
|
||||
description: continuable
|
||||
? 'Run as a background subagent that keeps its conversation and return its subagent id. '
|
||||
+ 'It does not report its result back; send it more work with send_message.'
|
||||
? 'Run as a background subagent that keeps its conversation and return only its subagent id. '
|
||||
+ 'This call never returns its result; send it more work with send_message.'
|
||||
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
|
||||
Reference in New Issue
Block a user