fix(subagent): cancel pending live delivery

This commit is contained in:
Dudu-0223
2026-07-27 19:49:22 +08:00
committed by imccyu
parent fea31a012d
commit 644bf00b86
19 changed files with 320 additions and 65 deletions

View 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-control/README.md
README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b
README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb

View File

@@ -1,8 +1,10 @@
# @deepseek-ai/dsh-tool-subagent-control
English | [中文](README.zh.md)
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered.
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered.
## Model Experience

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-tool-subagent-control
[English](README.md) | 中文
可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。
## 模型体验
### 工具 schema
#### 模型看到的内容
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。
#### Token 影响
每个父级请求支付固定的 schema 成本。
#### KV Cache 影响
前缀保持稳定;schema 不会在运行时改变。
### 投递结果
#### 模型看到的内容
消息加入运行中的激活时返回 `message delivered to running task <taskId>`;消息启动一次从持久化存储恢复的激活时返回 `message started task <taskId> continuing subagent <subagent_id>`。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。
#### Token 影响
每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。
#### KV Cache 影响
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。
- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。

View File

@@ -72,6 +72,7 @@ export function apply(ctx: Context): void {
SessionId(args.subagent_id),
message,
{ kind: 'coordinator', senderSessionId: parent.id },
exec.signal,
)
return Promise.resolve(result)
},

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -34,9 +34,10 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
return { ctx, parent, adapter }
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -44,9 +45,15 @@ function text(result: { content: { type: string; text?: string }[] }): string {
}
let calls = 0
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
function callTool(
ctx: Context,
name: string,
args: unknown,
agent?: unknown,
signal: AbortSignal = testToolSignal,
) {
return ctx.tools.execute({
signal: testToolSignal,
signal,
callId: CallId(`call-${++calls}`),
name,
arguments: args,
@@ -114,6 +121,40 @@ describe('dsh-tool-subagent-control', () => {
expect(text(result)).toBe('message delivered to running task subagent-9')
})
it('cancels a pending live-delivery wait when the tool signal aborts', async () => {
const { ctx, parent, adapter } = await setup(['hang'])
const started = ctx.subagents.startContinuable({
provider: 'spawn',
label: 'hung work',
request: { prompt: [{ type: 'text', text: 'wait' }], parent },
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const deliveryStarted: PromiseWithResolvers<void> = Promise.withResolvers()
const sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents)
ctx.subagents.sendMessage = (agent, childId, message, source, signal) => {
const delivery = sendMessage(agent, childId, message, source, signal)
deliveryStarted.resolve()
return delivery
}
const controller = new AbortController()
const execution = callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'follow up',
}, parent, controller.signal)
await deliveryStarted.promise
controller.abort('parent tool cancelled')
const result = await execution
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('CANCELLED')
expect(ctx.agents.get(started.childId)).toBeUndefined()
const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent)
expect(snapshot.status).toBe('killed')
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false)
})
it('reports a delivery failure as an errored, not-delivered result', async () => {
const { ctx, parent } = await setup([])
const result = await callTool(ctx, 'send_message', {