feat(subagent): make final reports the continuable return contract
A continuable child could finish without giving its parent a usable result: the report tool was described as optional and its default delivery stayed quiet. Install the child-scoped report obligation (tool description plus the tool:report prompt section), default reportDelivery to wakeup, bind shipped subagent_fork delegations to one-shot so fork keeps its prefix reuse, and extend the ACP snapshot harness to pin child system prompts alongside child tool schemas.
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/subagent-fork/README.md
|
||||
README.md: 55475aee7841e91960de79887dfe9bf37afdf9da
|
||||
README.zh.md: 379218970c406162309659277a4fcb2da885d9ea
|
||||
README.md: 2bd72058ab7d112f8f317a33842fc4d95b719017
|
||||
README.zh.md: 40f9e34c5a8ae8c74de2ae0c9e4676866f0bd083
|
||||
@@ -39,7 +39,7 @@ Forking duplicates retained completed history into separate child requests; the
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only.
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)).
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
@@ -58,3 +58,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.
|
||||
- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md).
|
||||
@@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。
|
||||
|
||||
### 父 agent 工具结果(间接)
|
||||
|
||||
@@ -58,3 +58,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。
|
||||
- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。
|
||||
@@ -74,6 +74,12 @@ class ForkProvider implements SubagentProvider {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(fork-continuable-prefix-reuse): no shipped composition calls this —
|
||||
// they bind fork to `backgroundMode: one-shot` because a continuable child's
|
||||
// `report` tool and prompt section precede the inherited history, defeating
|
||||
// the prefix reuse a fork exists for. Reopening needs a byte-identical child
|
||||
// system prompt and tool schemas; see issue #2124 and
|
||||
// .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
|
||||
// The fork prefix is captured ONCE, at creation: it becomes part of the
|
||||
// child's own durable transcript, so a later cold resume replays that
|
||||
|
||||
@@ -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-report/README.md
|
||||
README.md: 1772b72d83563e89ed0e3814e515b06a2b36fe0f
|
||||
README.zh.md: 94101fbc9611a04c56ed3a9171889499c9144c21
|
||||
README.md: b38b6b541d9c03174de1515df7a0babe5da055b4
|
||||
README.zh.md: 0c5c063eded1e9057ee8886f03dd6494dd9eeed8
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
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.
|
||||
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, and installs the prompt section that instructs the child to use it. The package registers a continuable-child setup contribution instead of a global tool, so the tool and its guidance exist 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 parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
The child-scoped `tool:report` prompt section instructs the child to call `report` once before finishing, with a self-contained answer, and earlier whenever a partial finding changes what the parent should do next. The instruction is guidance, not enforcement: the mechanism still accepts zero or many calls in one turn, and no runtime path rejects a child that never reports. 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 parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
|
||||
`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.
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `wakeup` (the default) uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. It is the default because a parent that already parked has no other reason to look, so quiet delivery would leave an accepted report unread until something unrelated woke it. `quiet` 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. 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.
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` and its guidance into a minted child scope, and returns the one disposer revoking both. 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
|
||||
|
||||
@@ -18,15 +18,15 @@ The contribution body is exported as `installReportTool(childCtx, ctx, delivery)
|
||||
|
||||
#### 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.
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that the child must report once before finishing, that reporting reaches only the Agent that started the child, and that it does not end the turn. It carries no recipient or delivery-mode parameter. The separate `tool:report` prompt section repeats the obligation outside the schema, where a child that ignores tool descriptions still reads it.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost per continuable-child request, and none in any other Agent's requests.
|
||||
Fixed schema and prompt-section 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.
|
||||
Prefix-stable within a child; neither the schema nor the section changes at runtime. Removing the package revokes both from resident children, which changes their next request prefix.
|
||||
|
||||
### Report result
|
||||
|
||||
@@ -36,7 +36,7 @@ Prefix-stable within a child; the schema does not change at runtime. Removing th
|
||||
|
||||
#### 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.
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: waking delivery makes it the sole ordinary message of one new parent turn, while quiet delivery adds it to the parent's next request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -61,6 +61,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
|
||||
- **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.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both 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.
|
||||
- **No rate limiting** — the default `wakeup` mode can amplify model work when nested children report frequently; a deployment that accepts unread reports over that amplification selects `quiet`.
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
|
||||
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`wakeup`(默认值)使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。之所以作为默认值:已经停驻的父级没有别的理由再去查看,静默投递会让一条已被接受的报告一直无人阅读,直到别的事件把父级唤醒。`quiet` 使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
|
||||
作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。
|
||||
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 及其指引安装到新创建的子级作用域中,并返回同时撤销两者的唯一 disposer。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。
|
||||
每个可继续子级请求支付固定的 schema 与提示词 section 成本,其他任何 Agent 的请求均无此成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。
|
||||
子级中的前缀保持稳定;schema 与该 section 都不会在运行时改变。移除本包会从驻留子级中撤销两者,从而改变其下一次请求前缀。
|
||||
|
||||
### 上报结果
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:唤醒投递会使该内容成为一个新父级轮次中唯一的普通消息,静默投递则把内容加入父级的下一次请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -61,6 +61,6 @@
|
||||
- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。
|
||||
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。
|
||||
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指引;移除本包则会立即从驻留子级撤销两者。
|
||||
- **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,默认的 `wakeup` 模式会放大模型工作量;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* The child-scoped `report` tool and its usage guidance, 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
|
||||
*/
|
||||
@@ -11,86 +11,131 @@ import z from '@deepseek-ai/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 type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
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']
|
||||
// The contribution registers only through childCtx.tools and
|
||||
// childCtx.systemPrompt, but declaring both services makes Loader ordering fail
|
||||
// at load instead of at the next child materialization.
|
||||
export const inject = ['subagents', 'tools', 'systemPrompt']
|
||||
|
||||
/** Guidance order after every per-tool section a continuable child can carry. */
|
||||
const REPORT_SECTION_ORDER = 117
|
||||
|
||||
/** 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.
|
||||
* Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later
|
||||
* parent turn; `quiet` adds context without waking, so a parked parent learns
|
||||
* of the report only when something else wakes it.
|
||||
*/
|
||||
reportDelivery?: SubagentReportDelivery
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'),
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Install `report` into one continuable child's scope.
|
||||
* @param childCtx - child-scoped context receiving the tool.
|
||||
* Install `report` and its usage guidance into one continuable child's scope.
|
||||
* Both registrations are owned by that scope and are therefore invisible to the
|
||||
* child's parent and siblings.
|
||||
* @param childCtx - child-scoped context receiving the tool and the guidance.
|
||||
* @param ctx - service context used for delivery.
|
||||
* @param delivery - resolved deployment scheduling policy.
|
||||
* @returns disposer for this one registration.
|
||||
* @returns disposer that attempts both child registrations before reporting cleanup failures.
|
||||
*/
|
||||
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 },
|
||||
const disposeSection = childCtx.systemPrompt.section({
|
||||
name: 'tool:report',
|
||||
order: REPORT_SECTION_ORDER,
|
||||
text: 'Deliver your result with the report tool before you finish: call it once with a self-contained '
|
||||
+ 'answer. The agent that started you shares your workspace but does not automatically receive your '
|
||||
+ 'transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can '
|
||||
+ 'use. Report earlier as well whenever a partial finding changes what that agent should do next; '
|
||||
+ 'reporting never ends your turn.',
|
||||
})
|
||||
let disposeTool: () => void
|
||||
try {
|
||||
disposeTool = childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this once before you finish, with a '
|
||||
+ 'self-contained final result, and earlier for progress or findings that change what that agent does '
|
||||
+ 'next. That agent shares your workspace but does not automatically receive your transcript, tool '
|
||||
+ 'output, or reasoning, so finishing your work is not itself a result. 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: 'Actionable content for your parent; summarize conclusions and reference relevant shared paths.',
|
||||
},
|
||||
},
|
||||
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 }
|
||||
},
|
||||
}))
|
||||
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 }
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
disposeSection()
|
||||
} catch (rollbackError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'failed to register the report tool and roll back its prompt guidance',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [disposeTool, disposeSection]) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'failed to revoke report tool and prompt registrations')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the continuable-child contribution.
|
||||
* @param ctx - context carrying tools and the subagent service.
|
||||
* @param ctx - context carrying tools, the system prompt, and the subagent service.
|
||||
* @param config - deployment scheduling policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
// Config() applies the schema default ('quiet') at runtime; the schemastery
|
||||
// return type keeps the input's optional shape, so assert the resolved
|
||||
// shape here — no runtime fallback exists or is wanted.
|
||||
// Config() applies the schema default at runtime; the schemastery return
|
||||
// type keeps the input's optional shape, so assert the resolved one.
|
||||
const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery }
|
||||
ctx.subagents.registerContinuableSetup(childCtx =>
|
||||
installReportTool(childCtx, ctx, reportDelivery))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assembleContextFor } 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'
|
||||
@@ -96,6 +97,17 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
|
||||
})
|
||||
}
|
||||
|
||||
/** Occupy the child-local report name to force installation rollback. */
|
||||
function registerReportConflict(child: Agent): () => void {
|
||||
return child.ctx.tools.register({
|
||||
name: 'report',
|
||||
description: 'conflicting report fixture',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'object', properties: {} }, render: () => [] },
|
||||
execute: () => Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Reports already visible or still pending in one Agent. */
|
||||
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
|
||||
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
@@ -113,6 +125,12 @@ function renderedText(result: { content: { type: string; text?: string }[] }): s
|
||||
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
|
||||
}
|
||||
|
||||
/** The prompt sections one agent's scope assembles, by name. */
|
||||
async function sectionNames(ctx: Context, agent: Agent): Promise<string[]> {
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
return assembly.sections.map(section => section.name)
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-report', () => {
|
||||
it('registers report only in continuable child scopes', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
@@ -309,16 +327,101 @@ describe('dsh-tool-subagent-report', () => {
|
||||
const { ctx, parent, fiber } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
|
||||
expect(await sectionNames(ctx, child)).toContain('tool:report')
|
||||
|
||||
await fiber?.dispose()
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool: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')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
await late.dispose()
|
||||
})
|
||||
|
||||
it('rolls back prompt guidance when tool registration fails', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
|
||||
expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('aggregates a registration failure with a prompt rollback failure', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
const rollbackFailure = new Error('prompt rollback listener failed')
|
||||
let promptChanges = 0
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
promptChanges++
|
||||
if (promptChanges === 2) throw rollbackFailure
|
||||
})
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
off()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure')
|
||||
expect(failure.errors).toHaveLength(2)
|
||||
expect(String(failure.errors[0])).toContain('already registered in this scope')
|
||||
expect(failure.errors[1]).toBe(rollbackFailure)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('attempts both revocations and aggregates change-listener failures', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const dispose = tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
const toolFailure = new Error('tool removal listener failed')
|
||||
const promptFailure = new Error('prompt removal listener failed')
|
||||
const offTool = ctx.on('tools/change', () => { throw toolFailure })
|
||||
const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
offPrompt()
|
||||
offTool()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure')
|
||||
expect(failure.errors).toEqual([toolFailure, promptFailure])
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('scopes the report guidance to the child that owns it', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const { child } = await startChild(ctx, parent, 'first child')
|
||||
const { child: sibling } = await startChild(ctx, parent, 'second child')
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child))
|
||||
const guidance = assembly.sections.find(section => section.name === 'tool:report')
|
||||
// Pins the model-visible instruction that makes the return channel a
|
||||
// contract rather than an option the child may quietly skip.
|
||||
expect(guidance?.text).toContain('Deliver your result with the report tool before you finish')
|
||||
expect(guidance?.text).toContain('reporting never ends your turn')
|
||||
|
||||
expect(await sectionNames(ctx, parent)).not.toContain('tool:report')
|
||||
// A sibling installs its own copy; neither child can observe the other's.
|
||||
expect(await sectionNames(ctx, sibling)).toContain('tool:report')
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
|
||||
.not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('rolls back materialization when a setup contribution revokes itself', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const self: { revoke?: () => void } = {}
|
||||
@@ -405,10 +508,29 @@ describe('dsh-tool-subagent-report', () => {
|
||||
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.inject).toEqual(['subagents', 'tools', 'systemPrompt'])
|
||||
// Waking is the default because a report that never wakes its parent
|
||||
// cannot deliver a result to an agent that already parked.
|
||||
expect(tool.Config({}).reportDelivery).toBe('wakeup')
|
||||
expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
|
||||
})
|
||||
|
||||
it('wakes the parent under the default configuration', async () => {
|
||||
const { ctx, parent, adapter } = await setup({ config: {} })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const enqueues: string[] = []
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (agent === parent) {
|
||||
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
|
||||
}
|
||||
})
|
||||
|
||||
expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false)
|
||||
expect(enqueues).toEqual(['queued'])
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user