refactor(subagent): drop host-user authority and split lifecycle publication

Remove the host-user continuation capability and the public residency query,
then separate the seam's public event payloads from its internal lifecycle
control interfaces.

`followup()` now takes the exact live direct parent `Agent` instead of a
`SubagentAuthority` union. No production adapter ever supplied user authority,
so the `UserAuthorityGrant` brand token existed only to stop a forged
discriminant from bypassing the direct-parent check — deleting the branch
retires the token, its mint method, and that attack surface together.

Narrowing `parent` from `Agent | undefined` to `Agent` removes three special
cases, including the path where a parentless epoch dispatched its lifecycle
events unscoped. Scoped-versus-global dispatch is now decided by the event, not
by whether a caller happened to have a parent.

`activationState()` had no caller; `ActivationState`, `ActivationObserver`, and
`ContinuationHost` are package-private.

New `src/lifecycle.ts` owns the contained emitter, the one-shot run observer,
and the Activation observer, while `SubagentRunInfo`/`SubagentRunEndInfo` move
to `src/types.ts` beside the other consumer-facing contracts. Those payloads are
public API — dsh-jsonrpc, hooks-claude, and the package invariant all consume
them — whereas the observer is a contract between two in-package collaborators,
so they no longer share a home merely for both being lifecycle-shaped. The
service keeps ownership of the scope carrier: `scopeTarget()` composes the
service's own context filter, so a narrowed stand-in would silently change
scope filtering.

Also drops now-unused dsh-tasks-local and dsh-tool-tasks dev dependencies, and
corrects the README claim that a pre-residency failure emits a terminal edge —
that path only ever rethrew.
This commit is contained in:
Dudu-0223
2026-07-30 17:47:48 +08:00
committed by Tianyi Cui
parent 7428cdf41e
commit 853f4d5cfb
25 changed files with 524 additions and 671 deletions

View File

@@ -889,16 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Establish one durable continuable child and deliver its initial prompt.\n * Resolves when the child\'s inbox accepts that prompt, without waiting for the\n * turn to start or for the message to reach the Session log; any earlier\n * failure rejects with no ids and rolls back the child entirely.\n * @param spec - provider, delegation request, and caller cancellation.\n * @returns the durable child id and the accepted prompt\'s message id.\n * @throws when continuation services are unavailable or materialization fails.\n */',
},
{
signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so parent and user messages\n * share one observable order.\n * @param authority - trusted parent or user authority for this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, authority is rejected,\n * or the message was not admitted.\n */',
},
{
signature: 'userAuthority(): SubagentAuthority',
jsDoc: '/**\n * Host-user authority for continuable operations, which may continue any\n * durable child without its parent. A composition passes this only to a\n * trusted host adapter carrying real human interaction; a model-facing tool\n * uses `{ kind: \'parent\', agent }` from its own execution context instead.\n * @returns the authority a host adapter supplies to {@link followup}.\n */',
},
{
signature: 'activationState(childId: SessionId): ActivationState | undefined',
jsDoc: '/**\n * Read one durable child\'s live residency state.\n * @param childId - durable child session id.\n * @returns its Activation state, or `undefined` when no Activation is live.\n * @throws when continuation services are unavailable.\n */',
signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */',
},
{
signature: 'async drainContinuable(): Promise<void>',
@@ -1579,10 +1571,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'ActivationState',
declaration: 'export type ActivationState = \'running\' | \'waiting\' | \'settled\';',
},
{
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
@@ -2695,10 +2683,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
},
{
name: 'SubagentAuthority',
declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n readonly grant: UserAuthorityGrant;\n};',
},
{
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
@@ -3039,10 +3023,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TypertTypeModel',
declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}',
},
{
name: 'UserAuthorityGrant',
declaration: 'export type UserAuthorityGrant = {\n readonly __brand: \'SubagentUserAuthority\';\n};',
},
{
name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',

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: 6a8016dc71d928c1770cc0769f99d2cb53c6b035
README.zh.md: 53f553bd2747bebac0f2d42ac80ad8b6eb660c45
README.md: 1b38d493efa1dbe86464ad376649ff37914067da
README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49

View File

@@ -30,14 +30,12 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `list()` | Return provider names in insertion order. |
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(authority, childId, content, { source, signal })` | Deliver one later message to a continuable child as its next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `AgentMessageId`. A resident child's inbox accepts it directly (waking a `waiting` Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `userAuthority()` | Mint the host-user authority a trusted adapter passes to `followup()`. Composition hands this only to a host carrying real human interaction; a model-facing tool uses its own `{ kind: 'parent', agent }` instead. |
| `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `drainContinuable()` | Close continuable admission synchronously, then dispose every live Activation forest child-first. A host calls this before disposing top-level agents so no descendant outlives the runtime that owns its teardown. An aggregate error surfaces after every branch settles when any failed. |
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. 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, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
Authority for continuable operations comes from a trusted host interaction or an exact live Agent tool context: `SubagentAuthority` is `{ kind: 'parent', agent }` or `{ kind: 'user', grant }`, whose grant only `userAuthority()` mints so the discriminant alone cannot claim it. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority. Parent authority requires the exact live direct parent recorded in the child's durable header; user authority may continue any child, and may cold-resume it without loading its historical parent.
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
@@ -74,17 +72,17 @@ A local run publishes an ordinary child agent/session before `start()` fulfills,
A continuable child has one durable Session and at most one process-local **Activation** — one residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The Agent inbox is the only turn queue, so the continuation manager owns residency while the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper.
The public residency state has three values derived from Agent quiescence and the owned-child set, not a second state machine: `running` (an active admission, open turn, or waking inbox work), `waiting` (quiescent but still owning at least one undisposed child), and `settled` (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn, so parent and user messages share one observable order with no steering of the current turn. Routing depends only on residency: `running` enqueues, `waiting` wakes the same Agent, and an absent Activation cold-resumes a new one.
The manager derives three internal residency conditions from Agent quiescence and the owned-child set rather than maintaining a second state machine: running (an active admission, open turn, or waking inbox work), waiting (quiescent but still owning at least one undisposed child), and settled (quiescent with every owned child disposed, so the manager disposes the `AgentHandle` and removes the Activation). Every continuation message uses `Agent.followup()` and becomes one FIFO turn with no steering of the current turn. Routing depends only on residency: running enqueues, waiting wakes the same Agent, and an absent Activation cold-resumes a new one.
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input — so a user can cold-resume a persisted child without loading its historical parent.
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` or rejection reports `DURABILITY_FAILED` and still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting`.
## Lifecycle events
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each continuable Activation's residency 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 never becomes resident emits only the terminal edge, because it has no start edge to pair. 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.
Run events are scoped to the delegating parent; a user-resumed continuable child has no delegating parent, so its lifecycle reaches unscoped listeners globally. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
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.
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
@@ -104,6 +102,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
- **ACP children remain one-shot** — an ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn.
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent or user cannot redirect a turn already underway; the manager stores no current-turn controller state.
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state.
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.

View File

@@ -30,14 +30,12 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
| `list()` | 按插入顺序返回提供方名称。 |
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
| `startContinuable(spec)` | 建立一个持久化可继续子 agent并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(authority, childId, content, { source, signal })` | 将一条后续消息作为可继续子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `AgentMessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 `waiting` 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `userAuthority()` | 铸造可信 host 适配器传给 `followup()` 的 host 用户权限。组合装配仅将其交给承载真实人类交互的 host面向模型的工具改用自身执行上下文的 `{ kind: 'parent', agent }`。 |
| `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running``waiting``settled`);无实时 Activation 时返回 `undefined`。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 |
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose 子 agent。
可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority``{ kind: 'parent', agent }``{ kind: 'user', grant }`——其 grant 仅由 `userAuthority()` 铸造,因此仅凭判别式无法声明该权限。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent并且可以在不加载其历史父级的情况下将其冷恢复
续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。
@@ -74,17 +72,17 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
可继续子 agent 拥有一个持久化 Session 和至多一个进程内 **Activation**——即被重建的子 agent 的一个驻留时段,而不是请求、结果、取消或 Task 边界。Agent inbox 是唯一的轮次队列,因此继续执行管理器负责驻留,而 Agent 循环负责所有轮次排序与执行。任何可继续路径都不会创建 Task 或中间的承载结果的包装器。
公共驻留状态有三个取值,由 Agent 停稳状态和所拥有子集推导,而非第二个状态机:`running`(存在活跃准入、进行中的轮次或唤醒型 inbox 工作)、`waiting`(已停稳但仍拥有至少一个未 dispose 的子 agent`settled`(已停稳且所有拥有的子 agent 都已 dispose因此管理器 dispose `AgentHandle` 并移除 Activation。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,因此父级和用户消息共享同一个可观察顺序,且不会对当前轮次进行 steering中途引导。路由只取决于驻留状态`running` 入队、`waiting` 唤醒同一 Agent无 Activation 时则冷恢复一个新的。
管理器根据 Agent 停稳状态和所拥有子集推导三个内部驻留条件,而非维护第二个状态机running存在活跃准入、进行中的轮次或唤醒型 inbox 工作、waiting已停稳但仍拥有至少一个未 dispose 的子 agent、settled已停稳且所有拥有的子 agent 都已 dispose因此管理器 dispose `AgentHandle` 并移除 Activation。每条后续消息都使用 `Agent.followup()` 并成为一个 FIFO 轮次,且不会对当前轮次进行 steering中途引导。路由只取决于驻留状态running 入队、waiting 唤醒同一 Agent无 Activation 时则冷恢复一个新的。
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发——持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入——因此用户可以在不加载历史父级的情况下冷恢复持久化子 agent
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose子先于父。顶层及其他非继续执行的 Agent 没有 Activation处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`
## 生命周期事件
服务会为每次一次性运行以及每个可继续 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因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
运行事件受执行委派的父级作用域约束;用户恢复的可继续子 agent 没有执行委派的父级,因此其生命周期会全局到达无作用域的监听器。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
提供方新增和移除还会发出 `subagent/provider-added``subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
@@ -104,6 +102,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
- **ACP 子 agent 仍为一次性**ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
- **无 report 投递**MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级或用户无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态
- **无 host-user 继续执行**`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。
- **驻留仅限进程内**Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent但丢失的消息不会自动重放。

View File

@@ -38,6 +38,7 @@ import {
} from './child-agent.ts'
import { seedDescriptorTurn } from './descriptor-seed.ts'
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
import type { ActivationObserver } from './lifecycle.ts'
import { SubagentError } from './error.ts'
/** Attribution for a model coordinator's follow-up to one of its children. */
@@ -53,29 +54,6 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/**
* Who authorizes one continuable-subagent operation. Authority comes from a
* trusted host interaction or an exact live Agent tool context; durable
* {@link MessageSource} provenance never authorizes delivery.
*/
export type SubagentAuthority =
/** The exact live parent Agent whose tool context is making the call. */
| { readonly kind: 'parent'; readonly agent: Agent }
/**
* A trusted host adapter acting for the human user. The `grant` must be the
* exact token {@link SubagentService.userAuthority} minted, so a discriminant
* alone cannot claim this authority — any plugin holding `ctx.subagents`,
* including model-generated mount code, could otherwise forge it and bypass
* the direct-parent check.
*/
| { readonly kind: 'user'; readonly grant: UserAuthorityGrant }
/**
* Opaque proof that a caller obtained user authority from the service rather
* than constructing it. Only {@link SubagentService.userAuthority} mints one.
*/
export type UserAuthorityGrant = { readonly __brand: 'SubagentUserAuthority' }
/** What a caller asks for when starting a continuable background child. */
export interface ContinuableStartSpec {
/** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
@@ -106,44 +84,22 @@ export interface SubagentFollowupOptions {
}
/**
* The public residency state of one continuable child, derived from Agent
* quiescence and the owned-child set rather than a second state machine:
* The residency state of one continuable child, derived from Agent quiescence
* and the owned-child set rather than a second state machine:
* `running` — the Agent has an active admission or turn, or waking inbox work;
* `waiting` — the Agent is quiescent but still owns undisposed children;
* `settled` — quiescent with every owned child disposed, so the manager
* disposes the `AgentHandle` and removes the Activation.
*/
export type ActivationState = 'running' | 'waiting' | 'settled'
type ActivationState = 'running' | 'waiting' | 'settled'
/**
* Lifecycle observer for one Activation's residency epoch, so continuable
* children emit the same start/end pair as one-shot runs.
* Hooks the manager needs from the owning service. Declared here, by the
* dependent, so the manager states exactly what it requires instead of
* depending back on the whole {@link SubagentService}. Package-private: no
* consumer outside this package supplies a host.
*/
export interface ActivationObserver {
/**
* Publish the start edge once the epoch is resident.
* @param child - the resident child agent, whose log suffix bounds this epoch.
*/
start(child: Agent): void
/**
* Snapshot the child-dependent terminal facts while the child is still
* registered, because handle disposal unregisters it and consumers resolve it
* to read the child's own log and scope.
* @param child - the quiescent child agent about to be released.
*/
capture(child: Agent): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
* after the disposal outcome is known. Called only for a resident epoch: a
* failure before residency publishes no edge, because inventing one would
* report a lifecycle the child never had.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(failure: unknown): void
}
/** Hooks the manager needs from the owning service. */
export interface ContinuationHost {
interface ContinuationHost {
/**
* Resolve one provider's continuable-creation contribution, or reject when
* the provider is unknown or lacks the capability.
@@ -156,10 +112,10 @@ export interface ContinuationHost {
* Build the lifecycle observer for one Activation's residency epoch.
* @param provider - the provider name recorded in the durable descriptor.
* @param childId - the durable child session id.
* @param parent - the delegating parent for scoped dispatch, if any.
* @param parent - the exact live direct parent for scoped dispatch.
* @returns the observer whose edges this epoch publishes.
*/
observeActivation(provider: string, childId: SessionId, parent: Agent | undefined): ActivationObserver
observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver
}
/**
@@ -257,8 +213,6 @@ export class SubagentContinuationManager {
constructor(
private readonly ctx: Context,
private readonly host: ContinuationHost,
/** The single token that proves host-user authority for this manager. */
private readonly userGrant: UserAuthorityGrant,
) {
// Ordinary Cordis owner effects unwind in reverse registration order, which
// cannot express the dynamic child graph. Register the private scope's
@@ -274,17 +228,6 @@ export class SubagentContinuationManager {
}.bind(this), 'subagents.continuations()')
}
/**
* Read one durable child's live residency state.
* @param childId - the durable child session id.
* @returns its Activation state, or `undefined` when no Activation is live.
*/
activationState(childId: SessionId): ActivationState | undefined {
const activation = this.activations.get(childId)
if (activation === undefined) return undefined
return this.stateOf(activation)
}
/**
* Start one continuable background child: reserve its durable identity,
* resolve the provider's detached creation spec, create the child Agent
@@ -343,7 +286,7 @@ export class SubagentContinuationManager {
// 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' }, { kind: 'parent', agent: parent })
return this.submit(activation, request.prompt, { kind: 'user' }, parent)
})
return { childId, messageId }
}
@@ -353,20 +296,20 @@ export class SubagentContinuationManager {
* turn. Routing depends only on Activation residency: a `running` Activation
* enqueues, a `waiting` one wakes the same Agent, and an absent one
* cold-resumes a new Activation from the persisted Session. The Agent inbox
* is the only queue, so parent and user messages share one observable order.
* is the only queue, so every accepted message has one observable order.
*
* The caller signal owns lookup, materialization, and admission only until
* inbox acceptance; afterwards the accepted turn cannot be cancelled through
* this service.
* @param authority - trusted parent or user authority for this delivery.
* @param parent - the exact live direct parent authorizing this delivery.
* @param childId - the durable child session id.
* @param content - the user-role content to deliver.
* @param options - durable provenance and caller cancellation.
* @returns the accepted message's inbox id.
* @throws when authority, availability, or admission rejects the delivery.
* @throws when parent authority, availability, or admission rejects the delivery.
*/
async followup(
authority: SubagentAuthority,
parent: Agent,
childId: SessionId,
content: ContentBlock[],
options: SubagentFollowupOptions,
@@ -375,7 +318,7 @@ export class SubagentContinuationManager {
while (true) {
const live = await this.locks.run(childId, async () => {
const activation = this.activations.get(childId)
if (activation === undefined) return this.coldResume(authority, childId, content, options)
if (activation === undefined) return this.coldResume(parent, childId, content, options)
// A delivery that arrives after the disposal transaction began must not
// reach a handle being torn down; wait for release, then cold-resume.
/* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a
@@ -385,13 +328,13 @@ export class SubagentContinuationManager {
if (activation.disposal !== undefined) {
return activation.disposal.then(() => undefined, () => undefined)
}
await this.authorizeLive(authority, activation)
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, authority)
return this.submit(activation, content, options.source, parent)
})
/* 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. */
@@ -472,7 +415,7 @@ export class SubagentContinuationManager {
* descriptor is the whole reconstruction input.
*/
private async coldResume(
authority: SubagentAuthority,
parent: Agent,
childId: SessionId,
content: ContentBlock[],
options: SubagentFollowupOptions,
@@ -488,8 +431,8 @@ export class SubagentContinuationManager {
options.signal.throwIfAborted()
this.assertAdmitting()
// Authorize the persisted header before folding: only the durable child's
// direct parent — or the host user — may continue it.
this.authorizeLineage(authority, childId, loaded.meta.parentSession)
// exact live direct parent may continue it.
this.authorizeLineage(parent, childId, loaded.meta.parentSession)
// Fold only the child's own suffix: a fork seed replays the parent's log,
// which may carry an ANCESTOR's descriptor when the parent is itself a
// continuable child.
@@ -504,7 +447,7 @@ export class SubagentContinuationManager {
const activation = await this.materialize({
childId,
provider: descriptor.provider,
parent: authority.kind === 'parent' ? authority.agent : undefined,
parent,
agentOptions: {
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
@@ -513,7 +456,7 @@ export class SubagentContinuationManager {
signal: options.signal,
})
await this.rollbackIfAborted(activation, options.signal)
return this.submit(activation, content, options.source, authority)
return this.submit(activation, content, options.source, parent)
}
/**
@@ -540,7 +483,7 @@ export class SubagentContinuationManager {
private async materialize(inputs: {
childId: SessionId
provider: string
parent: Agent | undefined
parent: Agent
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
agentOptions: AgentOptions
@@ -639,8 +582,7 @@ export class SubagentContinuationManager {
* top-level or other non-continuation Agent has no Activation and stays
* outside the waiting graph.
*/
private acquireOwnership(parent: Agent | undefined, childId: SessionId): void {
if (parent === undefined) return
private acquireOwnership(parent: Agent, childId: SessionId): void {
const parentActivation = this.activations.get(parent.id)
if (parentActivation === undefined) return
if (parentActivation.disposal !== undefined) {
@@ -674,11 +616,11 @@ export class SubagentContinuationManager {
activation: Activation,
content: ContentBlock[],
source: MessageSource,
authority: SubagentAuthority,
parent: Agent,
): MessageId {
// Parent-originated delivery keeps the parent live through ownership, so
// establish it before the message can enter the child's inbox.
if (authority.kind === 'parent') this.acquireOwnership(authority.agent, activation.childId)
this.acquireOwnership(parent, activation.childId)
const message = createUserMessage({ content, source })
// `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its
// observers must see this Activation as busy before the call begins.
@@ -699,39 +641,25 @@ export class SubagentContinuationManager {
* 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(authority: SubagentAuthority, activation: Activation): Promise<void> {
private async authorizeLive(parent: Agent, activation: Activation): Promise<void> {
await Promise.resolve()
this.authorizeLineage(
authority,
parent,
activation.childId,
activation.handle.agent.session.header.parentSession,
)
}
/**
* Authorize one operation against the durable direct-parent lineage. User
* authority may continue any child without loading its parent; parent
* authority requires the exact live direct parent. Other agents, ancestors,
* teams, and workflows remain rejected until an explicit authority protocol
* exists.
* Authorize one operation against the durable direct-parent lineage. Other
* agents, ancestors, teams, workflows, and hosts remain rejected until an
* explicit authority protocol has a production consumer.
*/
private authorizeLineage(
authority: SubagentAuthority,
parent: Agent,
childId: SessionId,
parentSession: SessionId | undefined,
): void {
if (authority.kind === 'user') {
// Identity, not shape: a forged discriminant must not skip the
// direct-parent check for an arbitrary known child id.
if (authority.grant !== this.userGrant) {
throw new SubagentError(
`subagent "${childId}" delivery presented an invalid user-authority grant`,
'UNAUTHORIZED',
)
}
return
}
const parent = authority.agent
if (this.ctx.agents.get(parent.id) !== parent) {
throw new SubagentError(
`subagent "${childId}" delivery requires the exact live parent agent`,

View File

@@ -29,35 +29,31 @@
* @module @deepseek-ai/dsh-subagent
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
ContinuableCreateRequest,
ContinuableCreateSpec,
SubagentCapabilities,
SubagentProvider,
SubagentResult,
SubagentRun,
SubagentRunEndInfo,
SubagentRunInfo,
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
import { SubagentError } from './error.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
import SubagentContinuationManager from './continuation.ts'
import type {
ActivationObserver,
ActivationState,
UserAuthorityGrant,
ContinuableStart,
ContinuableStartSpec,
SubagentAuthority,
SubagentFollowupOptions,
} from './continuation.ts'
@@ -93,15 +89,12 @@ export {
} from './child-agent.ts'
export type { ChildComposition } from './child-agent.ts'
export type {
ActivationObserver,
ActivationState,
UserAuthorityGrant,
ContinuableStart,
ContinuableStartSpec,
CoordinatorMessageSource,
SubagentAuthority,
SubagentFollowupOptions,
} from './continuation.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
declare module 'cordis' {
interface Context {
@@ -144,55 +137,25 @@ declare module 'cordis' {
}
}
/** Observe-only identifying detail for a ready subagent run. */
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
}
/** Observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/** Named provider registry with one-shot runs and continuable-child operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
private continuations: SubagentContinuationManager | undefined
/**
* The process-local proof of host-user authority. Minted here so the value is
* unguessable and unforgeable: a caller must obtain it from
* {@link userAuthority}, which composition hands only to trusted host
* adapters.
* The contained lifecycle-edge publisher. Built here because scoped dispatch
* keys its carrier by this exact service instance, whose own context filter
* composes into the carrier.
*/
private readonly userGrant = Object.freeze({
__brand: 'SubagentUserAuthority',
}) as UserAuthorityGrant
private readonly emitLifecycle: LifecycleEmitter
constructor(ctx: Context) {
super(ctx, 'subagents')
this.emitLifecycle = createLifecycleEmitter(this.ctx, parent => scopeTarget(this, parent))
ctx.inject(['agents'], (childCtx: Context) => {
const manager = new SubagentContinuationManager(childCtx, {
prepareContinuable: (name, request) => this.prepareContinuable(name, request),
observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent),
}, this.userGrant)
})
this.continuations = manager
childCtx.effect(() => () => {
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
@@ -218,45 +181,24 @@ export class SubagentService extends Service {
* Deliver one later message to a continuable child as its next FIFO turn. A
* resident child's Agent inbox accepts it directly (waking a `waiting`
* Activation), while an absent one is cold-resumed from its persisted
* Session. The Agent inbox is the only queue, so parent and user messages
* share one observable order.
* @param authority - trusted parent or user authority for this delivery.
* Session. The Agent inbox is the only queue, so every accepted message has
* one observable order.
* @param parent - the exact live direct parent authorizing this delivery.
* @param childId - durable child session id.
* @param content - user-role content to deliver.
* @param options - durable provenance and caller cancellation, which stops the
* operation only before inbox acceptance.
* @returns the accepted message's inbox id.
* @throws when continuation services are unavailable, authority is rejected,
* or the message was not admitted.
* @throws when continuation services are unavailable, parent authority is
* rejected, or the message was not admitted.
*/
async followup(
authority: SubagentAuthority,
parent: Agent,
childId: SessionId,
content: ContentBlock[],
options: SubagentFollowupOptions,
): Promise<MessageId> {
return this.requireContinuations().followup(authority, childId, content, options)
}
/**
* Host-user authority for continuable operations, which may continue any
* durable child without its parent. A composition passes this only to a
* trusted host adapter carrying real human interaction; a model-facing tool
* uses `{ kind: 'parent', agent }` from its own execution context instead.
* @returns the authority a host adapter supplies to {@link followup}.
*/
userAuthority(): SubagentAuthority {
return { kind: 'user', grant: this.userGrant }
}
/**
* Read one durable child's live residency state.
* @param childId - durable child session id.
* @returns its Activation state, or `undefined` when no Activation is live.
* @throws when continuation services are unavailable.
*/
activationState(childId: SessionId): ActivationState | undefined {
return this.requireContinuations().activationState(childId)
return this.requireContinuations().followup(parent, childId, content, options)
}
/**
@@ -329,7 +271,7 @@ export class SubagentService extends Service {
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
return this.observeRun(name, request.parent, await provider.start(request))
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(request))
}
/**
@@ -373,112 +315,15 @@ export class SubagentService extends Service {
}
/**
* Emit the start/end lifecycle pair for one continuable Activation's
* residency epoch. Observers see the same vocabulary as a one-shot run, so a
* child's start and settlement remain observable without exposing whether the
* manager materialized, woke, or cold-resumed it. Creation failure before
* residency reports only the terminal edge.
* Build the lifecycle observer for one continuable Activation's residency
* epoch, so the manager publishes its edges without owning event dispatch.
*/
private observeActivation(
provider: string,
childId: SessionId,
parent: Agent | undefined,
parent: Agent,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
// A cold resume replays earlier turns, so this epoch's telemetry must come
// from the suffix it actually produced — never the whole session, which
// would report a previous epoch's answer when this one opened no turn.
let boundary = 0
// Assigned by `capture()`, which the disposal path always runs before
// `settle()`; a resident epoch therefore always has its facts by then.
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
stopReason: 'completed',
}
let settled = false
return {
start: (child: Agent): void => {
boundary = child.session.events.length
this.emitLifecycle('subagent/start', identity, parent)
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
}
},
settle: (failure: unknown): void => {
// Exactly one terminal edge per epoch: host shutdown, manager unload,
// child release, and normal settlement all converge on one disposal.
/* v8 ignore next -- the memoized disposal already collapses those callers into a
* single settle(); this guard keeps the edge single if that memoization ever changes. */
if (settled) return
settled = true
const output = failure === undefined ? captured.output : undefined
this.emitLifecycle('subagent/end', {
...identity,
stopReason: failure === undefined ? captured.stopReason : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
}
}
/** Emit the start/end lifecycle pair for one accepted run and return it. */
private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun {
const runId = SubagentRunId(randomUUID())
const lifecycleIdentity = {
runId,
provider: name,
id: run.id,
local: run.localAgent !== undefined,
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
this.emitLifecycle('subagent/end', {
...lifecycleIdentity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
}, parent)
},
() => {
this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
},
)
this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
return run
}
/**
* Emit lifecycle events with per-listener synchronous and asynchronous
* exception containment. Payloads are borrowed immutable values.
*/
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent | undefined): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent | undefined): void
private emitLifecycle(name: 'subagent/provider-removed', info: string): void
private emitLifecycle(
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent ,
): void {
// A user-resumed continuable child has no delegating parent to key the
// carrier by, so its lifecycle reaches unscoped listeners globally.
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
return createActivationObserver(this.emitLifecycle, provider, childId, parent)
}
/** Reject the first requested capability that the provider lacks. */
@@ -500,57 +345,4 @@ export class SubagentService extends Service {
}
}
/**
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
* about whether the model errored, hit its token ceiling, or was cancelled, so
* deriving the reason from disposal would report failed work as completed.
* @param events - this epoch's own event suffix.
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
*/
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(events)?.data.reason
// No ordinary turn closed, so nothing failed either.
if (reason === undefined) return 'completed'
switch (reason.kind) {
case 'max-tokens':
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'
case 'completed':
return 'completed'
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
* backend that adds a variant; treating an unnameable reason as success would
* report failed work as completed. */
default:
return 'error'
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -2,8 +2,7 @@
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { SubagentProvider } from './types.ts'
import type { SubagentRunEndInfo, SubagentRunInfo } from './index.ts'
import type { SubagentProvider, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent'

View File

@@ -0,0 +1,244 @@
/**
* Lifecycle-edge publication for both subagent shapes: the contained emitter,
* the one-shot run observer, and the continuable Activation observer.
*
* The public payload contracts ({@link SubagentRunInfo},
* {@link SubagentRunEndInfo}) live in `./types.ts` with the rest of the seam's
* consumer-facing types; this module owns only the implementation and the
* package-private {@link ActivationObserver} the continuation manager consumes.
* Keeping the internal control interface out of the published surface is
* deliberate: the observer's `start`/`capture`/`settle` ordering is a contract
* between this module and one in-package caller, not something a plugin may
* depend on.
*
* @module @deepseek-ai/dsh-subagent/lifecycle
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
/**
* Lifecycle observer for one Activation's residency epoch, so continuable
* children emit the same start/end pair as one-shot runs. Package-private: the
* continuation manager is the only consumer, and its call ordering is an
* in-package contract rather than a published extension seam.
*/
export interface ActivationObserver {
/**
* Publish the start edge once the epoch is resident.
* @param child - the resident child agent, whose log suffix bounds this epoch.
*/
start(child: Agent): void
/**
* Snapshot the child-dependent terminal facts while the child is still
* registered, because handle disposal unregisters it and consumers resolve it
* to read the child's own log and scope.
* @param child - the quiescent child agent about to be released.
*/
capture(child: Agent): void
/**
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
* after the disposal outcome is known. Called only for a resident epoch: a
* failure before residency publishes no edge, because inventing one would
* report a lifecycle the child never had.
* @param failure - the teardown or durability failure, or `undefined` on success.
*/
settle(failure: unknown): void
}
/**
* Publish one lifecycle edge with per-listener exception containment. Run edges
* carry the delegating parent that keys scoped dispatch; provider removal has no
* parent carrier and reaches listeners unscoped.
*
* The service owns this closure because scoped dispatch keys its carrier by the
* exact service instance, whose own context filter composes into the carrier;
* a narrowed stand-in would silently change scope filtering.
*/
export type LifecycleEmitter = {
(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
(name: 'subagent/provider-removed', info: string): void
}
/**
* Build the contained lifecycle emitter this seam publishes every edge through.
* Every listener is independently contained: a synchronous throw or a rejected
* returned promise is logged without starving peer listeners, changing the run,
* or — for provider removal, which fires from a disposer — breaking teardown.
* @param ctx - the service's own context, owning dispatch and the logger.
* @param carrier - resolve the scoped dispatch carrier for one delegating parent.
* @returns the emitter both observers and the provider registry publish through.
*/
export function createLifecycleEmitter(
ctx: Context,
carrier: (parent: Agent) => object,
): LifecycleEmitter {
return (
name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed',
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void => {
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [carrier(parent), name, info]
for (const callback of ctx.events.dispatch('emit', dispatchArgs)) {
try {
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
}
/**
* Emit the start/end lifecycle pair for one accepted one-shot run.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider that established the run.
* @param parent - the delegating parent keying scoped dispatch.
* @param run - the ready run whose settlement closes the pair.
* @returns the same run, unchanged.
*/
export function observeRun(
emit: LifecycleEmitter,
provider: string,
parent: Agent,
run: SubagentRun,
): SubagentRun {
const identity = {
runId: SubagentRunId(randomUUID()),
provider,
id: run.id,
local: run.localAgent !== undefined,
}
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
emit('subagent/end', {
...identity,
stopReason: result.stopReason,
lastAssistantMessage: result.output,
}, parent)
},
() => {
emit('subagent/end', { ...identity, stopReason: 'error' }, parent)
},
)
emit('subagent/start', identity, parent)
return run
}
/**
* Build the observer for one continuable Activation's residency epoch. Observers
* see the same vocabulary as a one-shot run, so a child's start and settlement
* remain observable without exposing whether the manager materialized, woke, or
* cold-resumed it. Creation failure before residency emits no lifecycle edge.
* @param emit - the contained lifecycle emitter.
* @param provider - the provider name recorded in the durable descriptor.
* @param childId - the durable child session id.
* @param parent - the exact live direct parent keying scoped dispatch.
* @returns the observer whose edges this epoch publishes.
*/
export function createActivationObserver(
emit: LifecycleEmitter,
provider: string,
childId: SessionId,
parent: Agent,
): ActivationObserver {
const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true }
// A cold resume replays earlier turns, so this epoch's telemetry must come
// from the suffix it actually produced — never the whole session, which
// would report a previous epoch's answer when this one opened no turn.
let boundary = 0
// Assigned by `capture()`, which the disposal path always runs before
// `settle()`; a resident epoch therefore always has its facts by then.
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
stopReason: 'completed',
}
return {
start: (child: Agent): void => {
boundary = child.session.events.length
emit('subagent/start', identity, parent)
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
}
},
settle: (failure: unknown): void => {
const output = failure === undefined ? captured.output : undefined
emit('subagent/end', {
...identity,
stopReason: failure === undefined ? captured.stopReason : 'error',
...output === undefined ? {} : { lastAssistantMessage: output },
}, parent)
},
}
}
/**
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
* about whether the model errored, hit its token ceiling, or was cancelled, so
* deriving the reason from disposal would report failed work as completed.
* @param events - this epoch's own event suffix.
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
*/
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
const reason = findLastMessageTurnEnd(events)?.data.reason
// No ordinary turn closed, so nothing failed either.
if (reason === undefined) return 'completed'
switch (reason.kind) {
case 'max-tokens':
return 'max-tokens'
case 'aborted':
case 'interrupted':
case 'disposed':
return 'aborted'
case 'error':
return 'error'
case 'completed':
return 'completed'
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
* backend that adds a variant; treating an unnameable reason as success would
* report failed work as completed. */
default:
return 'error'
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}

View File

@@ -1,5 +1,10 @@
/**
* Request, result, and capability contracts for {@link SubagentProvider}.
* The seam's consumer-facing contracts: request, result, and capability types
* for {@link SubagentProvider}, plus the `subagent/start` and `subagent/end`
* payloads that plugins and hosts observe. Internal control interfaces belong
* with their implementation — the lifecycle observer in `./lifecycle.ts`, the
* continuation host in `./continuation.ts` — so this module stays the published
* surface rather than a bag of everything type-shaped.
*
* @module @deepseek-ai/dsh-subagent/types
*/
@@ -22,6 +27,41 @@ export function SubagentRunId(id: string): SubagentRunId {
return id as SubagentRunId
}
/**
* Observe-only identifying detail for a ready subagent run, carried by
* `subagent/start`. One-shot runs and continuable Activation epochs share this
* payload, so an observer sees the same vocabulary for both.
*/
export interface SubagentRunInfo {
/** Unique identity shared with the paired terminal event. */
readonly runId: SubagentRunId
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
}
/**
* Observe-only outcome detail for a settled subagent run, carried by
* `subagent/end` and paired with one {@link SubagentRunInfo} by `runId`.
*/
export interface SubagentRunEndInfo {
/** Unique identity shared with the paired start event. */
readonly runId: SubagentRunId
/** The provider that ran it. */
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks

View File

@@ -19,7 +19,7 @@ import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
} from '../src/index.ts'
import type { SubagentAuthority, SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -109,12 +109,12 @@ function userTexts(events: readonly SessionEvent[]): string[] {
function followup(
ctx: Context,
authority: SubagentAuthority,
parent: Agent,
childId: SessionId,
content: ReturnType<typeof message>,
signal: AbortSignal = testSignal,
) {
return ctx.subagents.followup(authority, childId, content, {
return ctx.subagents.followup(parent, childId, content, {
source: { kind: 'user' },
signal,
})
@@ -123,7 +123,6 @@ function followup(
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
expect(ctx.subagents.activationState(childId)).toBeUndefined()
expect(ctx.agents.get(childId)).toBeUndefined()
}, { timeout: 5_000 })
}
@@ -303,7 +302,8 @@ describe('SubagentService.startContinuable', () => {
await fresh.plugin(AgentLoop, { agents: [] })
await fresh.plugin(SubagentService)
await fresh.plugin(SubagentSpawn, { providerName: 'spawn' })
await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('resume routeless'))
const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {})
await followup(fresh, freshParent, started.childId, message('resume routeless'))
const resumed = await vi.waitFor(() => {
const found = fresh.agents.get(started.childId)
@@ -353,7 +353,7 @@ describe('SubagentService.startContinuable', () => {
expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' })
// Cold resume reconstructs the declared composition from that descriptor.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('resume it'))
await followup(ctx, parent, started.childId, message('resume it'))
await waitNoActivation(ctx, started.childId)
const resumed = await ctx.sessionPersistence.load(started.childId)
expect(hasUserText(resumed.events, 'resume it')).toBe(true)
@@ -372,19 +372,19 @@ describe('SubagentService.followup residency routing', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)
expect(ctx.subagents.activationState(started.childId)).toBe('running')
expect(child?.status).toBe('running')
// Both origins queue behind the open turn, in call order.
const parentMessage = await followup(ctx, { kind: 'parent', agent: parent }, started.childId, message('from parent'))
const userMessage = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('from user'))
expect(parentMessage).not.toBe(userMessage)
// Both messages queue behind the open turn, in call order.
const firstMessage = await followup(ctx, parent, started.childId, message('first follow-up'))
const secondMessage = await followup(ctx, parent, started.childId, message('second follow-up'))
expect(firstMessage).not.toBe(secondMessage)
// Still the same Activation: no second child Agent was created.
expect(ctx.agents.get(started.childId)).toBe(child)
releaseFirst.resolve(undefined)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(userTexts(loaded.events)).toEqual(['child task', 'from parent', 'from user'])
expect(userTexts(loaded.events)).toEqual(['child task', 'first follow-up', 'second follow-up'])
})
it('cold-resumes a settled child into a new Activation', async () => {
@@ -392,7 +392,7 @@ describe('SubagentService.followup residency routing', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const messageId = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('continue please'))
const messageId = await followup(ctx, parent, started.childId, message('continue please'))
expect(messageId).toBeTypeOf('string')
await waitNoActivation(ctx, started.childId)
@@ -421,12 +421,13 @@ describe('SubagentService.followup residency routing', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests.length).toBeGreaterThanOrEqual(2) })
await vi.waitFor(() => {
expect(ctx.subagents.activationState(started.childId)).toBe('waiting')
expect(child.status).toBe('idle')
expect(ctx.agents.get(started.childId)).toBe(child)
}, { timeout: 5_000 })
// Waiting retains the handle: the same Agent is still live.
expect(ctx.agents.get(started.childId)).toBe(child)
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('while waiting'))
await followup(ctx, parent, started.childId, message('while waiting'))
// Woken back to running on the SAME Activation.
expect(ctx.agents.get(started.childId)).toBe(child)
@@ -437,58 +438,16 @@ describe('SubagentService.followup residency routing', () => {
expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting'])
})
it('rejects a forged user-authority grant', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
// Any plugin holding `ctx.subagents` can write this shape, so shape alone
// must not skip the direct-parent check for an arbitrary known child id.
const forged = { kind: 'user', grant: { __brand: 'SubagentUserAuthority' } } as unknown as SubagentAuthority
await expect(followup(ctx, forged, started.childId, message('not really the user')))
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
// The service-minted grant is accepted.
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('really the user')))
.resolves.toBeTypeOf('string')
await waitNoActivation(ctx, started.childId)
})
it('rejects a parent that is not the durable direct parent', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
await expect(followup(ctx, { kind: 'parent', agent: stranger }, started.childId, message('mine now')))
await expect(followup(ctx, stranger, started.childId, message('mine now')))
.rejects.toThrow(/belongs to another parent session/)
})
it('lets user authority cold-resume a child without loading its historical parent', async () => {
const { ctx, parent, root } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await ctx.sessionPersistence.load(started.childId)
// A fresh runtime over the same store has no parent Agent at all.
const fresh = new Context()
await mountAgentLoopTestDependencies(fresh)
await fresh.plugin(JsonlSessionPersistence, { root: root! })
await fresh.plugin(AgentLoop, { agents: [] })
await fresh.plugin(SubagentService)
await fresh.plugin(SubagentSpawn, { providerName: 'spawn' })
fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')]))
expect(fresh.agents.get(SessionId('parent'))).toBeUndefined()
await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('user continues'))
await waitNoActivation(fresh, started.childId)
const loaded = await fresh.sessionPersistence.load(started.childId)
expect(hasUserText(loaded.events, 'user continues')).toBe(true)
// The historical parent was never reconstructed.
expect(fresh.agents.get(SessionId('parent'))).toBeUndefined()
})
it('reports an unresumable child whose persisted log has no supported descriptor', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// A ONE-SHOT child persists a log but never seeds a descriptor.
@@ -502,13 +461,13 @@ describe('SubagentService.followup residency routing', () => {
const oneShotId = run.id
await run.dispose()
await expect(followup(ctx, ctx.subagents.userAuthority(), oneShotId, message('continue')))
await expect(followup(ctx, parent, oneShotId, message('continue')))
.rejects.toThrow(/no supported continuation state/)
})
it('reports an unknown child id as unavailable', async () => {
const { ctx } = await setup([])
await expect(followup(ctx, ctx.subagents.userAuthority(), SessionId('missing'), message('hello')))
const { ctx, parent } = await setup([])
await expect(followup(ctx, parent, SessionId('missing'), message('hello')))
.rejects.toMatchObject({ code: 'NOT_RESUMABLE' })
})
@@ -524,7 +483,7 @@ describe('SubagentService.followup residency routing', () => {
// exactly one side wins the cutoff. A delivery that loses awaits release and
// cold-resumes rather than reaching a handle being torn down.
const delivery = child.whenIdle().then(() =>
followup(ctx, ctx.subagents.userAuthority(), started.childId, message('raced')))
followup(ctx, parent, started.childId, message('raced')))
await expect(delivery).resolves.toBeTypeOf('string')
await waitNoActivation(ctx, started.childId)
@@ -550,7 +509,8 @@ describe('continuable child ownership', () => {
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => {
expect(ctx.subagents.activationState(started.childId)).toBe('waiting')
expect(child.status).toBe('idle')
expect(ctx.agents.get(started.childId)).toBe(child)
}, { timeout: 5_000 })
// Child-first: the parent handle is retained while the grandchild is live.
expect(ctx.agents.get(started.childId)).toBe(child)
@@ -565,8 +525,7 @@ describe('continuable child ownership', () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
// The top-level parent has no Activation of its own.
expect(ctx.subagents.activationState(parent.id)).toBeUndefined()
// The top-level parent remains independently registered after its child settles.
expect(ctx.agents.get(parent.id)).toBe(parent)
})
})
@@ -652,7 +611,7 @@ describe('continuable durability and teardown', () => {
await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' })
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('too late')))
await expect(followup(ctx, parent, started.childId, message('too late')))
.rejects.toMatchObject({ code: 'DRAINING' })
})
@@ -663,7 +622,7 @@ describe('continuable durability and teardown', () => {
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
// Accepted into the inbox, but this queued turn never opens.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('never logged'))
await followup(ctx, parent, started.childId, message('never logged'))
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
@@ -707,7 +666,7 @@ describe('continuable review regressions', () => {
const controller = new AbortController()
controller.abort('caller gave up')
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('cancelled'), controller.signal))
await expect(followup(ctx, parent, started.childId, message('cancelled'), controller.signal))
.rejects.toThrow()
// Nothing was enqueued, so no later turn can carry it.
@@ -732,7 +691,7 @@ describe('continuable review regressions', () => {
// A cold resume is a new epoch: it must report its OWN answer, never the
// previous epoch's, which the replayed transcript still contains.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
@@ -746,11 +705,11 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => {
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
})
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
@@ -819,7 +778,7 @@ describe('continuable review regressions', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
// Queue a turn, then cancel so it is discarded rather than dequeued. The
// Activation must still reach settlement instead of waiting on that id.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('discarded'))
await followup(ctx, parent, started.childId, message('discarded'))
const drained = ctx.subagents.drainContinuable()
hold.resolve(undefined)
@@ -845,7 +804,7 @@ describe('continuable review regressions', () => {
child.cancel({ kind: 'user' })
}
})
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('doomed'))
await followup(ctx, parent, started.childId, message('doomed'))
off()
releaseFirst.resolve(undefined)
@@ -861,7 +820,7 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens.
ctx.on('agent/prompt-submit', async (subject, _content, _source, _signal, next) => {
ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => {
if (subject === parent) return next()
return { kind: 'block', reason: 'blocked by policy' }
})
@@ -873,30 +832,35 @@ describe('continuable review regressions', () => {
expect(ends[0]!.stopReason).toBe('completed')
})
it('never reports settled while an accepted message is still in the inbox', async () => {
it('retains the Activation while an accepted message is still in the inbox', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('first'), gate: releaseFirst.promise },
{ chunks: textResponse('second') },
])
const { ctx, parent } = await setupWith(adapter)
const states: (string | undefined)[] = []
const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/enqueue', (agent) => {
if (agent.session.header.parentSession !== undefined) {
states.push(ctx.subagents.activationState(agent.id))
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
}
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('queued'))
const child = ctx.agents.get(started.childId)
await followup(ctx, parent, started.childId, message('queued'))
expect(states.length).toBeGreaterThan(0)
expect(states).not.toContain('settled')
expect(registeredAtEnqueue.length).toBeGreaterThan(0)
expect(registeredAtEnqueue).not.toContain(false)
expect(ctx.agents.get(started.childId)).toBe(child)
releaseFirst.resolve(undefined)
await waitNoActivation(ctx, started.childId)
expect(adapter.requests).toHaveLength(2)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(hasUserText(loaded.events, 'queued')).toBe(true)
})
})
@@ -913,7 +877,7 @@ describe('continuable lifecycle observation', () => {
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
// A cold resume is a NEW epoch with its own pair.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
await followup(ctx, parent, started.childId, message('again'))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
@@ -926,10 +890,19 @@ describe('continuable lifecycle observation', () => {
})
describe('continuable public surface', () => {
it('exposes no cancellation, steering, or report operation', async () => {
it('exposes no host authority, residency query, cancellation, steering, or report operation', async () => {
const { ctx } = await setup([])
const subagents: Record<string, unknown> = ctx.subagents as unknown as Record<string, unknown>
for (const absent of ['cancel', 'kill', 'steer', 'steerContinuable', 'report', 'resume']) {
for (const absent of [
'activationState',
'cancel',
'kill',
'report',
'resume',
'steer',
'steerContinuable',
'userAuthority',
]) {
expect(subagents[absent]).toBeUndefined()
}
// No steering tool and no report tool are registered by this seam.
@@ -957,7 +930,7 @@ describe('continuable public surface', () => {
const controller = new AbortController()
controller.abort('caller gave up')
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('aborted'), controller.signal))
await expect(followup(ctx, parent, started.childId, message('aborted'), controller.signal))
.rejects.toThrow()
const loaded = await ctx.sessionPersistence.load(started.childId)
@@ -975,7 +948,7 @@ describe('continuable public surface', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const controller = new AbortController()
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('survives'), controller.signal)
await followup(ctx, parent, started.childId, message('survives'), controller.signal)
// After acceptance the manager owns the Activation independently.
controller.abort('caller gave up')
@@ -1004,13 +977,13 @@ describe('continuable errors', () => {
}).continuations
manager.activations.delete(started.childId)
await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('hello')))
await expect(followup(ctx, parent, started.childId, message('hello')))
.rejects.toThrow(SubagentError)
expect(ctx.agents.get(started.childId)).toBe(child)
hold.resolve(undefined)
})
it('rejects parent authority whose agent is no longer the live registry entry', async () => {
it('rejects a parent that is no longer the live registry entry', async () => {
const { ctx, parent } = await setup([textResponse('first')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
const child = await vi.waitFor(() => {
@@ -1021,7 +994,7 @@ describe('continuable errors', () => {
// A stale parent reference: same id, not the exact live entry.
const stale = { ...parent, id: parent.id } as unknown as Agent
await expect(followup(ctx, { kind: 'parent', agent: stale }, started.childId, message('stale')))
await expect(followup(ctx, stale, started.childId, message('stale')))
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
void child
})
@@ -1127,7 +1100,7 @@ describe('continuable errors', () => {
.toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' })
// The resumed Activation runs on the declared route, not the parent's.
await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again'))
await followup(ctx, parent, started.childId, message('again'))
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model')
})

View File

@@ -133,7 +133,7 @@ describe('SubagentService', () => {
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
await expect(subagents.followup(
subagents.userAuthority(),
fakeParent(),
SessionId('child'),
[{ type: 'text', text: 'hello' }],
{ source: { kind: 'user' }, signal: new AbortController().signal },

View File

@@ -46,8 +46,6 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -61,7 +61,7 @@ export function apply(ctx: Context): void {
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const messageId = await ctx.subagents.followup(
{ kind: 'parent', agent: parent },
parent,
SessionId(args.subagent_id),
message,
{