fix(subagent): cancel pending live delivery
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/README.md
|
||||
README.md: 3d5d5e7498b1700c07486cc6e894e72fed681bec
|
||||
README.zh.md: eb26c79665d387a1e779050dad476d8672f67642
|
||||
README.md: c0811eb3bd76543b4a07e7242772e38bd51db67e
|
||||
README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d
|
||||
|
||||
@@ -31,7 +31,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
|
||||
| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. |
|
||||
| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. |
|
||||
| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
|
||||
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability.
|
||||
|
||||
|
||||
@@ -2,34 +2,38 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程中、另一进程中,还是通过未来的传输机制运行。
|
||||
subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。
|
||||
|
||||
## 包(package)的角色
|
||||
## 包角色
|
||||
|
||||
该系列包把稳定接口与实现、面向模型的工具分开:
|
||||
该能力族把稳定接口与实现、面向模型的工具分开:
|
||||
|
||||
| 包 | 角色 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型和生命周期事件。 |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 |
|
||||
| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 |
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容,并支持从持久化存储恢复的进程内子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
|
||||
|
||||
多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。
|
||||
|
||||
## 服务 API
|
||||
|
||||
`SubagentService` 有四个主要操作:
|
||||
`SubagentService` 有七个主要操作:
|
||||
|
||||
| 成员 | 含义 |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 |
|
||||
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
|
||||
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
|
||||
| `list()` | 按插入顺序返回提供方名称。 |
|
||||
| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理启动过程中取得的全部资源。 |
|
||||
| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 |
|
||||
| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 |
|
||||
| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 |
|
||||
| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 |
|
||||
|
||||
`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消正在运行的子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。
|
||||
`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。
|
||||
|
||||
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。
|
||||
|
||||
@@ -42,7 +46,11 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
- `toolFilter`:应用请求的子 agent 工具限制;
|
||||
- `persona`:应用每个子 agent 独立的 persona。
|
||||
|
||||
运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。
|
||||
运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。
|
||||
|
||||
## 持久化描述符
|
||||
|
||||
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在任何 Task 存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在提供方分发前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
|
||||
## 委派深度
|
||||
|
||||
@@ -52,13 +60,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 所有权与生命周期
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使启动过程中已取得的资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开该子 agent 本身,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。
|
||||
|
||||
服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
|
||||
服务只会在 `start()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
|
||||
|
||||
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
|
||||
|
||||
@@ -66,17 +74,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再对运行执行 dispose(资源释放),然后才返回。后台委派不会改变该 seam;消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task。可继续后台委派会调用 `ctx.subagents.startContinuable()`;只有 `ctx.tasks` 和 `ctx.agents` 可用时,其内部管理器才会存在,而会话持久化按每项继续执行操作解析。收集和取消使用共享 Task 工具。完整契约见[后台 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/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`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema,以及前台或通用后台结果,同时子 agent 工作上下文只留在子 agent 中。
|
||||
通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接使缓存失效;具名消费方负责请求前缀的任何变化。
|
||||
不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **运行时 steering 和延续只是 seam 能力**:当前工具中没有消费 `sendMessage` 和 `resume` 的面向模型消费方。
|
||||
- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。
|
||||
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。
|
||||
|
||||
@@ -248,6 +248,8 @@ export class SubagentContinuationManager {
|
||||
* @param childId - the stable child session id.
|
||||
* @param message - the user-role content to deliver.
|
||||
* @param source - caller-supplied attribution retained across either route.
|
||||
* @param signal - caller cancellation. During live delivery, abort cancels
|
||||
* the shared activation and rejects only after it reaches quiescence.
|
||||
* @returns whether the message `steered` the existing Task or `started` a new one.
|
||||
*/
|
||||
async sendMessage(
|
||||
@@ -255,13 +257,14 @@ export class SubagentContinuationManager {
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
signal: AbortSignal,
|
||||
): Promise<SendMessageResult> {
|
||||
this.assertOwnership(childId)
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation !== undefined) {
|
||||
return {
|
||||
route: 'steered',
|
||||
taskId: await this.steerActivation(activation, parent, childId, message, source),
|
||||
taskId: await this.steerActivation(activation, parent, childId, message, source, signal),
|
||||
}
|
||||
}
|
||||
return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) }
|
||||
@@ -298,6 +301,7 @@ export class SubagentContinuationManager {
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
signal: AbortSignal,
|
||||
): Promise<TaskId> {
|
||||
const taskId = activation.taskId
|
||||
/* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */
|
||||
@@ -323,9 +327,23 @@ export class SubagentContinuationManager {
|
||||
'NOT_DELIVERED',
|
||||
)
|
||||
}
|
||||
const cancelActivation = (): void => {
|
||||
activation.controller.abort(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', cancelActivation, { once: true })
|
||||
if (signal.aborted) {
|
||||
cancelActivation()
|
||||
signal.removeEventListener('abort', cancelActivation)
|
||||
return await this.cancelledLiveDelivery(activation, childId)
|
||||
}
|
||||
try {
|
||||
await run.steer(message, source)
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch {
|
||||
return await this.cancelledLiveDelivery(activation, childId, error)
|
||||
}
|
||||
// Confirmed steering lost the race with request admission. Deliberately no
|
||||
// cold-resume fallback here: that would attach the message to a turn the
|
||||
// caller did not observe.
|
||||
@@ -334,10 +352,30 @@ export class SubagentContinuationManager {
|
||||
'NOT_DELIVERED',
|
||||
{ cause: error },
|
||||
)
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancelActivation)
|
||||
}
|
||||
return taskId
|
||||
}
|
||||
|
||||
/** Reject a cancelled live delivery only after its shared activation is quiescent. */
|
||||
private async cancelledLiveDelivery(
|
||||
activation: ActiveActivation,
|
||||
childId: SessionId,
|
||||
cause?: unknown,
|
||||
): Promise<never> {
|
||||
/* v8 ignore if -- a published run implies the producer assigned `done` before its provider await resolved. */
|
||||
if (activation.done === undefined) {
|
||||
throw new Error('published subagent activation has no settlement promise')
|
||||
}
|
||||
await activation.done
|
||||
throw new SubagentError(
|
||||
`subagent "${childId}" live delivery was cancelled; the message was not delivered`,
|
||||
'CANCELLED',
|
||||
cause === undefined ? undefined : { cause },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-resume a persisted child into a fresh Task-backed activation. The
|
||||
* Task owns its `AbortController` before descriptor lookup: the load,
|
||||
|
||||
@@ -228,6 +228,8 @@ export class SubagentService extends Service {
|
||||
* @param childId - durable child session id.
|
||||
* @param message - user-role content to deliver.
|
||||
* @param source - durable caller attribution.
|
||||
* @param signal - caller cancellation; while live delivery awaits admission,
|
||||
* abort cancels the shared activation so the wait reaches quiescence.
|
||||
* @returns the existing steered Task or newly started Task.
|
||||
*/
|
||||
sendMessage(
|
||||
@@ -235,8 +237,9 @@ export class SubagentService extends Service {
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
signal: AbortSignal,
|
||||
): Promise<SendMessageResult> {
|
||||
return this.requireContinuations().sendMessage(parent, childId, message, source)
|
||||
return this.requireContinuations().sendMessage(parent, childId, message, source, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,14 +119,16 @@ const coordinatorSource = {
|
||||
kind: 'coordinator',
|
||||
senderSessionId: SessionId('parent'),
|
||||
} as const
|
||||
const testSendSignal = new AbortController().signal
|
||||
|
||||
function sendMessage(
|
||||
ctx: Context,
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
content: ReturnType<typeof message>,
|
||||
signal: AbortSignal = testSendSignal,
|
||||
) {
|
||||
return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' })
|
||||
return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal)
|
||||
}
|
||||
|
||||
describe('SubagentService.startContinuable', () => {
|
||||
@@ -422,6 +424,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
started.childId,
|
||||
message('also consider Y'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
)
|
||||
releaseFirst()
|
||||
const delivered = await delivery
|
||||
@@ -440,6 +443,27 @@ describe('SubagentService.sendMessage', () => {
|
||||
expect(steering?.data.message.source).toEqual(coordinatorSource)
|
||||
})
|
||||
|
||||
it('cancels the active Task without enqueueing when live delivery is already aborted', async () => {
|
||||
const { ctx, parent, adapter } = await setup(['hang'])
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller already cancelled')
|
||||
|
||||
await expect(sendMessage(
|
||||
ctx,
|
||||
parent,
|
||||
started.childId,
|
||||
message('must not enqueue'),
|
||||
controller.signal,
|
||||
)).rejects.toMatchObject({ code: 'CANCELLED' })
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, 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('rejects before acknowledgement when terminal policy prevents steering admission', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', 'structured_output', { answer: 7 }),
|
||||
@@ -473,6 +497,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
started.childId,
|
||||
message('follow-up that terminal policy rejects'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
)
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/message was not delivered/)
|
||||
@@ -495,6 +520,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
started.childId,
|
||||
message('and then?'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
)
|
||||
expect(followUp.route).toBe('started')
|
||||
expect(followUp.taskId).not.toBe(started.taskId)
|
||||
|
||||
6
packages/subagent/tool-subagent-control/README.i18n.yaml
Normal file
6
packages/subagent/tool-subagent-control/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-control/README.md
|
||||
README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b
|
||||
README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb
|
||||
@@ -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
|
||||
|
||||
|
||||
42
packages/subagent/tool-subagent-control/README.zh.md
Normal file
42
packages/subagent/tool-subagent-control/README.zh.md
Normal 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 结算后重试。
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user