fix(subagent): close continuation lifecycle gaps

This commit is contained in:
Dudu-0223
2026-08-02 12:51:08 +08:00
committed by Tianyi Cui
parent 853f4d5cfb
commit a91b20f6be
29 changed files with 365 additions and 142 deletions
@@ -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 .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: df2aaa71dde4980bf2dd533c11254d0db8fe61b3
2026-07-28-continuable-subagent-conversations.zh.md: 4437e73a3fa2f4d2043d2cfffe71259754fddeef
2026-07-28-continuable-subagent-conversations.md: e0119975d5f815886d671959efdc3028a5929f46
2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767
@@ -36,11 +36,11 @@ The continuation manager owns activation admission, authority checks, the live o
The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise<ContinuableCreateSpec>` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `MessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log.
Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting.
Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager keeps one closing transaction visible to concurrent delivery and drain, disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. Failure before the residency start edge publishes no terminal edge, while failure after a published start closes the lifecycle pair through normal disposal.
`backgroundMode: 'one-shot' | 'continuable'` remains deployment policy. Configured continuable mode requires `prepareContinuable`; method presence replaces `SubagentProvider.resume?()` as the capability check, while a capable provider may still run one-shot work.
Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent, and the initial provider name is not a recovery capability; remote providers require a separate design.
Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. The initial provider name remains lifecycle provenance after that provider unregisters; it is not a recovery capability or a requirement for later residency. Remote providers require a separate design.
`SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent.
@@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha
Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add.
Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain.
Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain.
The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown.
@@ -182,8 +182,8 @@ The implementation pins these behaviors:
- A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state.
- `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice.
- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `MessageId`, without waiting for turn start or a Session-log write.
- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership.
- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent.
- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge.
- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent.
- A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`.
- `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery.
- Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn.
@@ -198,7 +198,7 @@ The implementation pins these behaviors:
- This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup.
- Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee.
- No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper.
- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools.
- A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal.
@@ -36,11 +36,11 @@ persisted Session
具名 subagent 提供方只参与准备初始创建规格,此时 `spawn``fork` 有所区别。其可选的 `prepareContinuable(request): Promise<ContinuableCreateSpec>` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `MessageId``ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。
inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。
inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会保留一个对并发投递和 drain 可见的关闭事务,dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。在驻留 start 事件发布前失败不会发布终止事件,start 发布后失败则通过正常 dispose 闭合生命周期配对。
`backgroundMode: 'one-shot' | 'continuable'` 仍是部署策略。配置为 continuable 时要求存在 `prepareContinuable`;该方法是否存在会取代 `SubagentProvider.resume?()` 成为能力检查,而具备该能力的提供方仍可运行 one-shot 工作。
冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn``SubagentProvider.resume?()``SubagentProviderResumeRequest` 均不存在初始提供方名称也不是恢复能力;远程提供方需要单独设计。
冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn``SubagentProvider.resume?()``SubagentProviderResumeRequest` 均不存在初始提供方注销后,其名称仍作为生命周期来源信息保留;它不是恢复能力,也不是后续驻留的必要条件。远程提供方需要单独设计。
`SubagentProvider.start()``SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。
@@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining 和激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。
activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
@@ -182,8 +182,8 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。
- `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。
- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `MessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。
- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。
- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发`SubagentProvider.resume?()``SubagentProviderResumeRequest` 均不存在。
- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件
- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()``SubagentProviderResumeRequest` 均不存在。
- 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun``SubagentProvider.start()``SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`
- `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。
- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。
@@ -198,7 +198,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。
- 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。
- 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。
- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。
- 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。
@@ -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 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md
2026-07-27-intent-named-subagent-continuation-operations.md: 9f29074add3517d0baf94516c56fa69085ef75c4
2026-07-27-intent-named-subagent-continuation-operations.zh.md: a748af1a6cf44bc552b492d43314bf5a4e95338d
2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724
2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md)
The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, its bare-`Agent` parameter with an explicit authority union, and provider `resume` dispatch with `prepareContinuable`.
The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`.
## Problem
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文
本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,以显式的 authority(授权)联合类型替换`Agent` 参数,并以 `prepareContinuable` 替换提供方 `resume` 派发。
本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留`Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。
## 问题
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330
architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1
architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d
architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f
+1 -1
View File
@@ -38,7 +38,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure |
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers and Activation-based continuations |
| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
+1 -1
View File
@@ -38,7 +38,7 @@
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 |
| `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 |
| `ctx.compact``ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方和由 Activation 支撑的继续执行 |
| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
+1 -1
View File
@@ -391,7 +391,7 @@ flowchart LR
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
+1 -1
View File
@@ -503,7 +503,7 @@ export interface Config {
}
```
Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts)
Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts)
## `@deepseek-ai/dsh-hooks-codex`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md
subagent.md: 81d09903bec3bd4767e73720be4a8d58c7837eb4
subagent.zh.md: 6fd6845b5ceedd81e02365680a534e6d0c726aaf
subagent.md: e160c596acb55f0e94cba84b8c79355c966eb51a
subagent.zh.md: 6b934a523fa0ea5d53ea9a670e56b72b7f785593
+1 -1
View File
@@ -341,7 +341,7 @@ interface SubagentProvider {
}
```
Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. Their `provider` field is provenance for the run or Activation epoch, not a claim that the provider remains registered when the edge is emitted.
## In-process backends: depth and seed
+1 -1
View File
@@ -343,7 +343,7 @@ interface SubagentProvider {
}
```
提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。
提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。其中的 `provider` 字段是 run 或 Activation 时段的来源信息,并不声明该 edge 发出时提供方仍处于注册状态。
## 进程内后端:深度与种子
+10 -8
View File
@@ -31,10 +31,9 @@ import {
type MatcherGroup,
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event
// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the
// SubagentStart/SubagentStop listeners below type-check.
import type {} from '@deepseek-ai/dsh-subagent'
// Pulls in the declaration-merged subagent events and the identity pairing their
// start/end edges.
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
export const name = 'hooks-claude'
@@ -119,6 +118,10 @@ export function apply(ctx: Context, config: Config): void {
// Emit-shaped points run detached, so track their chains; disposal aborts
// active hooks and drains continuations before resolving.
const detached = createDetachedRuns()
// Only the start edge guarantees registry access. Retain each local child
// through its paired end so stop hooks keep the session workspace after the
// handle unregisters the agent.
const subagentChildren = new Map<SubagentRunId, Agent>()
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
/**
@@ -276,6 +279,7 @@ export function apply(ctx: Context, config: Config): void {
// use the live child's workspace and the generic agent-type matcher subject.
ctx.on('subagent/start', (info) => {
const child = ctx.get('agents')?.get(info.id)
if (child !== undefined) subagentChildren.set(info.runId, child)
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
@@ -284,10 +288,8 @@ export function apply(ctx: Context, config: Config): void {
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) }))
})
ctx.on('subagent/end', (info) => {
// Look up the child (still recoverable: `subagent/end` fires from the service's detached
// `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the
// child's cwd, not the server default.
const child = ctx.get('agents')?.get(info.id)
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)
subagentChildren.delete(info.runId)
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
})
}
@@ -681,12 +681,11 @@ export function defineCoverageCases(group: CoverageGroup): void {
})
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
// receives that agent and runs in the child's cwd rather than the executor default.
const serverDir = dir()
const childDir = dir()
const marker = join(childDir, 'stopwhere')
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
const payload = join(childDir, 'stoppayload')
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] })
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
@@ -698,15 +697,22 @@ export function defineCoverageCases(group: CoverageGroup): void {
const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
const runId = SubagentRunId('run-stop')
const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true }
// Start is the registry-backed capture edge; end deliberately follows
// handle disposal, matching continuable Activation settlement.
ctx.emit(subagentCarrier(ctx), 'subagent/start', identity)
await childHandle.dispose()
expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined()
ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' })
await waitFor(() => existsSync(marker))
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
const { readFileSync } = await import('node:fs')
const where = readFileSync(marker, 'utf8').trim()
const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string }
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
await childHandle.dispose()
expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id })
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 1b38d493efa1dbe86464ad376649ff37914067da
README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49
README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457
README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0
+1 -1
View File
@@ -80,7 +80,7 @@ A continuation-managed parent Activation records each child Session id in an `ow
## Lifecycle events
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names.
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
+1 -1
View File
@@ -80,7 +80,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 生命周期事件
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId``local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId``local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
+124 -80
View File
@@ -36,6 +36,7 @@ import {
resolveChildAgentOptions,
resolveChildDepth,
} from './child-agent.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { seedDescriptorTurn } from './descriptor-seed.ts'
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
import type { ActivationObserver } from './lifecycle.ts'
@@ -248,6 +249,7 @@ export class SubagentContinuationManager {
this.requirePersistence()
const request = spec.request
const parent = request.parent
assertSubagentMaxDepth(request.maxDepth)
const childId = SessionId(randomUUID())
const childDepth = resolveChildDepth(parent, request.maxDepth)
// Snapshot before any await: invalid descriptor JSON rejects the call
@@ -282,11 +284,13 @@ export class SubagentContinuationManager {
composition: { persona: request.persona, toolFilter: request.toolFilter },
signal: spec.signal,
})
// Materialization published the Activation; an abort landing in that
// window — a `subagent/start` listener can cancel synchronously — must
// roll the child back instead of opening its first turn.
await this.rollbackIfAborted(activation, spec.signal)
return this.submit(activation, request.prompt, { kind: 'user' }, parent)
return this.submitMaterialized(
activation,
request.prompt,
{ kind: 'user' },
parent,
spec.signal,
)
})
return { childId, messageId }
}
@@ -328,13 +332,8 @@ export class SubagentContinuationManager {
if (activation.disposal !== undefined) {
return activation.disposal.then(() => undefined, () => undefined)
}
await this.authorizeLive(parent, activation)
// The caller signal owns admission until acceptance, so re-check it
// here: the outer check cannot cover an abort that landed while
// authorization yielded, and enqueueing afterwards would return a
// message id for a delivery the caller already cancelled.
options.signal.throwIfAborted()
return this.submit(activation, content, options.source, parent)
this.authorizeLive(parent, activation)
return this.submitAdmitted(activation, content, options.source, parent, options.signal)
})
/* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
* race reaches the retry below, which then cold-resumes a new Activation. */
@@ -455,23 +454,33 @@ export class SubagentContinuationManager {
composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
signal: options.signal,
})
await this.rollbackIfAborted(activation, options.signal)
return this.submit(activation, content, options.source, parent)
return this.submitMaterialized(activation, content, options.source, parent, options.signal)
}
/**
* Dispose a freshly materialized Activation when the caller signal won the
* handoff between publication and inbox acceptance, so an aborted operation
* never leaves a resident child.
* @param activation - the just-published Activation.
* @param signal - the caller signal owning admission until acceptance.
* Submit to a freshly materialized Activation or roll it back completely.
* @param activation - the just-published Activation to admit or release.
* @param content - the initial or resumed message content.
* @param source - durable provenance for the accepted message.
* @param parent - the live direct parent authorizing admission.
* @param signal - caller cancellation owning admission until acceptance.
* @returns the accepted inbox message id.
*/
private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise<void> {
if (!signal.aborted) return
/* v8 ignore next -- the swallow only covers a disposal fault during rollback, which
* must not mask the caller's abort as the operation's failure. */
await this.dispose(activation).catch(() => undefined)
signal.throwIfAborted()
private async submitMaterialized(
activation: Activation,
content: ContentBlock[],
source: MessageSource,
parent: Agent,
signal: AbortSignal,
): Promise<MessageId> {
try {
return this.submitAdmitted(activation, content, source, parent, signal)
} catch (error: unknown) {
/* v8 ignore next -- rollback disposal failures must not mask the
* pre-acceptance signal, drain, or lifecycle failure. */
await this.dispose(activation).catch(() => undefined)
throw error
}
}
/**
@@ -498,30 +507,24 @@ export class SubagentContinuationManager {
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
const observer = this.host.observeActivation(provider, childId, parent)
let handle: AgentHandle
try {
const { create } = inputs
handle = create === undefined
? await this.ownerCtx.agents.resume({
resumeSessionId: childId,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
: await this.ownerCtx.agents.create({
sessionId: childId,
meta: create.meta,
seed: create.seed,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
} catch (error: unknown) {
// Agent creation provides rollback before handle transfer, so nothing
// outlives this rejection; report the epoch that never became resident.
// No start edge was published, so this epoch has no lifecycle to close.
throw error
}
const { create } = inputs
// Agent creation owns rollback before handle transfer. A rejection leaves
// no resident Activation and therefore publishes no lifecycle edge.
const handle: AgentHandle = create === undefined
? await this.ownerCtx.agents.resume({
resumeSessionId: childId,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
: await this.ownerCtx.agents.create({
sessionId: childId,
meta: create.meta,
seed: create.seed,
agentOptions: inputs.agentOptions,
signal: inputs.signal,
setup,
})
const activation: Activation = {
childId,
@@ -540,42 +543,53 @@ export class SubagentContinuationManager {
inputs.signal.throwIfAborted()
this.assertAdmitting()
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
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
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.
observer.start(handle.agent)
} catch (error: unknown) {
// Roll the transfer back completely: the Activation leaves the map, the
// parent's ownership membership is released, and the created handle is
// disposed before this rejection surfaces. No lifecycle edge is published,
// because `observer.start()` below has not run for this epoch.
this.activations.delete(childId)
this.releaseOwnership(childId)
activation.disposal = handle.dispose()
/* v8 ignore next -- the created handle disposes cleanly on every rollback this
* transaction can reach; the catch only keeps a disposal fault from masking `error`. */
await activation.disposal.catch(() => undefined)
// Listener exceptions are contained by the lifecycle emitter; a start
// publication throw therefore leaves no residency edge to pair.
/* v8 ignore next -- rollback failure must not mask the admission failure
* that prevented this operation from returning an accepted message id. */
await this.rollbackUnpublished(activation).catch(() => undefined)
throw error
}
// Every accepted id leaves the inbox exactly once, through dequeue or
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
// quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */
if (activation.accepted.delete(item.message.id)) this.wake(activation)
})
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
// Deleting every id in the batch is unconditional; waking once afterwards
// costs nothing and avoids branching on which ids this manager admitted.
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.
observer.start(handle.agent)
this.watchSettlement(activation)
return activation
}
/**
* Release an Activation whose start edge was not published. The memoized
* transaction remains in the live map until handle disposal settles, so a
* concurrent drain or delivery observes the same closing boundary.
*/
private rollbackUnpublished(activation: Activation): Promise<void> {
return (activation.disposal ??= (async () => {
try {
await activation.handle.dispose()
} finally {
this.activations.delete(activation.childId)
this.releaseOwnership(activation.childId)
}
})())
}
/**
* Register the child in a continuation-managed parent's owned set before the
* child can run, so that parent cannot settle while the child is live. A
@@ -637,12 +651,36 @@ export class SubagentContinuationManager {
return message.id
}
/**
* Cross the final admission cutoff and submit without yielding. Signal abort,
* manager drain, or Activation disposal that wins before this synchronous
* span rejects without inbox acceptance.
*/
private submitAdmitted(
activation: Activation,
content: ContentBlock[],
source: MessageSource,
parent: Agent,
signal: AbortSignal,
): MessageId {
signal.throwIfAborted()
this.assertAdmitting()
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
* this field between the caller's live check and this no-await boundary. */
if (disposalOf(activation) !== undefined) {
throw new SubagentError(
`subagent "${activation.childId}" activation is being disposed; the message was not accepted`,
'ACTIVATION_CLOSING',
)
}
return this.submit(activation, content, source, parent)
}
/**
* Authorize delivery to a live Activation. A parent must be the exact live
* direct parent recorded in the child's durable header.
*/
private async authorizeLive(parent: Agent, activation: Activation): Promise<void> {
await Promise.resolve()
private authorizeLive(parent: Agent, activation: Activation): void {
this.authorizeLineage(
parent,
activation.childId,
@@ -762,6 +800,12 @@ export class SubagentContinuationManager {
// Capture the child-dependent edge data while the child is still live:
// handle disposal unregisters it, and consumers read its log and scope.
activation.observer.capture(activation.handle.agent)
} catch (error: unknown) {
failure ??= new SubagentError(
`subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
'ACTIVATION_TEARDOWN_FAILED',
{ cause: error },
)
} finally {
try {
await activation.handle.dispose()
+1 -1
View File
@@ -303,7 +303,7 @@ export class SubagentService extends Service {
return provider
}
/** Resolve the optional Task-backed continuation runtime or fail loud. */
/** Resolve the optional continuable-subagent manager or fail loud. */
private requireContinuations(): SubagentContinuationManager {
if (this.continuations === undefined) {
throw new SubagentError(
+5 -3
View File
@@ -43,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
}
if (eventName === 'subagent/start') {
const info = args[0] as SubagentRunInfo
if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`)
if (String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start runId and child id must be non-empty')
// Provider availability is an admission-time relationship. A ready
// one-shot run may outlive provider removal, and a cold-resumed Activation
// carries durable provider provenance without dispatching through it.
if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) {
fail('subagent/start provider, runId, and child id must be non-empty')
}
if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`)
stagedStarts.add(info)
+6 -2
View File
@@ -35,7 +35,11 @@ export function SubagentRunId(id: string): SubagentRunId {
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
/**
* Provider provenance for this run or Activation epoch. The named provider
* may be absent when an accepted run becomes ready or a persisted Activation
* cold-resumes, because neither lifecycle depends on continued registration.
*/
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
@@ -50,7 +54,7 @@ export interface SubagentRunInfo {
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
/** The same provider provenance carried by the paired start event. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
@@ -14,12 +14,14 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
} from '../src/index.ts'
import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
import * as SubagentInvariant from '../src/invariant.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -228,6 +230,24 @@ describe('SubagentService.startContinuable', () => {
})
})
it('rolls an unpublished Activation back when lifecycle publication fails', async () => {
const { ctx, parent } = await setup([textResponse('unused')])
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', info => void ends.push(info))
ctx.on('internal/dispatch', (_mode, eventName) => {
if (eventName === 'subagent/start') throw new Error('start publication failed')
}, { global: true })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toThrow(/start publication failed/)
await vi.waitFor(() => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
expect(ends).toEqual([])
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
})
it('rejects a continuable child that would exceed the configured depth cap', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.startContinuable({
@@ -237,6 +257,15 @@ describe('SubagentService.startContinuable', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
it('rejects an invalid continuable depth cap before provider preparation', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.startContinuable({
...startSpec(parent),
request: { prompt: message('deep'), parent, maxDepth: Number.NaN },
})).rejects.toThrow(/non-negative safe integer/)
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
})
it('omits undeclared composition fields from the descriptor', async () => {
const { ctx } = await setup([])
// A routeless parent declares no provider/model, and this start declares no
@@ -402,6 +431,38 @@ describe('SubagentService.followup residency routing', () => {
expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1)
})
it('cold-resumes after the initial provider unregisters', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
await ctx.plugin(InvariantService)
await ctx.plugin(SubagentInvariant)
const disposeProvider = ctx.subagents.registerProvider({
name: 'retired',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('one-shot start is not used') },
prepareContinuable: () => Promise.resolve({}),
})
const starts: SubagentRunInfo[] = []
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/start', info => void starts.push(info))
ctx.on('subagent/end', info => void ends.push(info))
const started = await ctx.subagents.startContinuable(startSpec(parent, 'retired'))
await waitNoActivation(ctx, started.childId)
disposeProvider()
expect(ctx.subagents.getProvider('retired')).toBeUndefined()
await expect(followup(ctx, parent, started.childId, message('continue without provider')))
.resolves.toBeTypeOf('string')
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
expect(starts.map(info => info.provider)).toEqual(['retired', 'retired'])
expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId))
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider'])
})
it('wakes a waiting Activation instead of cold-resuming it', async () => {
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
@@ -615,6 +676,48 @@ describe('continuable durability and teardown', () => {
.rejects.toMatchObject({ code: 'DRAINING' })
})
it('rejects an initial prompt when drain starts after materialization', async () => {
const { ctx, parent } = await setup([])
const drains: Promise<void>[] = []
const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) })
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
await Promise.all(drains)
expect(accepted).toEqual([])
expect(ctx.agents.list()).toEqual([parent])
})
it('admits a live follow-up before a later drain can begin disposal', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const order: string[] = []
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue')
}
})
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
const delivery = followup(ctx, parent, started.childId, message('before drain'))
// Let the child-lock operation reach the live admission cutoff. Admission
// and inbox submission must then complete in one synchronous span.
await Promise.resolve()
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
await expect(delivery).resolves.toBeTypeOf('string')
await drained
expect(order).toEqual(['enqueue', 'cancel'])
})
it('has no automatic replay for an accepted but unlogged message', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }])
@@ -744,6 +847,29 @@ describe('continuable review regressions', () => {
expect(ends[0]!.stopReason).toBe('error')
})
it('reports a pre-disposal teardown failure on the terminal edge', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', info => void ends.push(info))
const started = await ctx.subagents.startContinuable(startSpec(parent))
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, { observer: { capture: (child: Agent) => void } }>
}
}).continuations
const activation = manager.activations.get(started.childId)!
activation.observer.capture = () => { throw new Error('capture failed') }
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('error')
})
it('cancels a running turn before the final durability checkpoint', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }])
@@ -797,8 +923,8 @@ describe('continuable review regressions', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
// Cancel from the synchronous enqueue observer: the discard fires before
// `followup()` returns, so the id is discarded before it can be recorded.
// Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
@@ -815,6 +941,35 @@ describe('continuable review regressions', () => {
expect(hasUserText(loaded.events, 'doomed')).toBe(false)
})
it('releases older ids discarded during a later admission window', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const manager = (ctx.subagents as unknown as {
continuations: {
activations: Map<SessionId, { accepted: Set<MessageId> }>
}
}).continuations
const activation = manager.activations.get(started.childId)!
await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' })
}
})
await followup(ctx, parent, started.childId, message('doomed'))
off()
expect(activation.accepted.size).toBe(0)
releaseFirst.resolve(undefined)
await waitNoActivation(ctx, started.childId)
})
it('reports completed when no ordinary turn closed', async () => {
const { ctx, parent } = await setup([])
const ends: SubagentRunEndInfo[] = []
@@ -68,10 +68,10 @@ describe('subagent invariants', () => {
it('rejects malformed and unpaired run transitions', async () => {
const ctx = await setup()
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/)
ctx.emit('subagent/provider-added', provider('mock'))
expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) })
.toThrow(/provider, runId, and child id must be non-empty/)
expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) })
.toThrow(/runId and child id must be non-empty/)
.toThrow(/provider, runId, and child id must be non-empty/)
emitRun(ctx, 'subagent/start', start())
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/)
expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) })
@@ -79,4 +79,14 @@ describe('subagent invariants', () => {
expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) })
.toThrow(/identity diverges/)
})
it('accepts historical provider provenance after registration ends', async () => {
const ctx = await setup()
const historical = provider('historical')
ctx.emit('subagent/provider-added', historical)
ctx.emit('subagent/provider-removed', historical.name)
emitRun(ctx, 'subagent/start', start({ provider: historical.name }))
emitRun(ctx, 'subagent/end', end({ provider: historical.name }))
})
})
@@ -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: b62870217e0eaf57c1cd16204c703aada694d4f2
README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4
README.md: 5023862cba39769248a9f6cbe935d6397df39266
README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. 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 — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` 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. 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.
## Model Experience
@@ -4,7 +4,7 @@
可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它 `exec.agent` 提供准确实时父级权限(`{ kind: 'parent', 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 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
## 模型体验
+1 -1
View File
@@ -428,7 +428,7 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
note: 'Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.',
note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.',
},
{
key: 'tasks',