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:
+2
-2
@@ -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: a56da8ad389964dcc873a722a66e335062811f37
|
||||
2026-07-28-continuable-subagent-conversations.zh.md: 71089cd71ae6fda7712ffcc614852a483e13e3ba
|
||||
2026-07-28-continuable-subagent-conversations.md: df2aaa71dde4980bf2dd533c11254d0db8fe61b3
|
||||
2026-07-28-continuable-subagent-conversations.zh.md: 4437e73a3fa2f4d2043d2cfffe71259754fddeef
|
||||
+28
-29
@@ -10,11 +10,11 @@ This record replaces the Task-backed continuation manager from [Continuable back
|
||||
|
||||
The previous continuation manager made one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposed the child Agent, Task completion injected the completion notice, and later input reconstructed another Agent. That coupled a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox.
|
||||
|
||||
Giving queued parent requests to the continuation manager and user messages to the Agent would create two FIFOs with no single ordering authority. Giving both to Tasks instead duplicated the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly.
|
||||
Giving queued continuation requests to the manager while the Agent retained its own inbox would create two FIFOs with no single ordering authority. Giving all messages to Tasks instead duplicated the Agent loop's admission, cancellation, and quiescence machinery. `Agent.whenIdle()` cannot recover a per-request Task result because one running interval may drain multiple queued turns, and broad `Agent.cancel()` cannot remove one queued request exactly.
|
||||
|
||||
The runtime lifetime is also wider than one turn. A subagent can finish its own turn while a child it created is still running. Disposing the parent runtime at that point removes the Agent that still owns descendant teardown. Keeping every historical subagent resident instead would make memory use unbounded.
|
||||
|
||||
Users and parent Agents also need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule for both senders.
|
||||
Parent Agents need to send later work to the same live child without changing its current turn. Queueing every continuation message as a follow-up preserves one ordering rule.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -44,7 +44,7 @@ Cold resume does not dispatch through a subagent provider. The continuation mana
|
||||
|
||||
`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.
|
||||
|
||||
`ctx.subagents.followup(authority, childId, content, { source, signal })` remains the sole continuation-message operation. `authority` is either `{ kind: 'parent', agent }` or `{ kind: 'user' }`; the parent variant is admitted only from an exact live Agent tool context, while only a trusted host adapter can supply user authority. `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation.
|
||||
`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; `source` remains durable provenance and grants no authority. The model-facing `send_message` tool keeps only its stable `subagent_id` and `message` fields and always submits a follow-up turn. Both start and follow-up return the accepted `MessageId`, and neither reports how the manager materialized the Activation.
|
||||
|
||||
For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `MessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child.
|
||||
|
||||
@@ -52,13 +52,13 @@ For start and follow-up, the caller signal owns lookup, materialization, and adm
|
||||
|
||||
The Session owns the stable child identity, transcript, direct-parent lineage, delegation depth, and versioned continuation descriptor. `SessionHeader.parentSession` is durable provenance and an authorization input; it is not a live routing capability and does not imply that the historical parent is resident.
|
||||
|
||||
An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. A user-authorized cold resume does not load the historical parent Agent. A parent-originated resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction.
|
||||
An idle historical Session has no `AgentHandle`. The first authorized `next-turn` delivery resumes an Activation from the persisted Session and submits the message to its inbox. Cold resume uses the exact live parent Agent for authorization and, when that parent has an Activation, ownership; it never uses the parent for reconstruction.
|
||||
|
||||
The Activation directly owns the published `AgentHandle` until it settles, while the manager's private activation-owner scope is its structural Cordis owner. The continuable path creates no intermediate result-bearing execution wrapper, including `SubagentRun`; one-shot delegation remains unchanged and outside this lifecycle. Remote providers are out of scope here and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed.
|
||||
|
||||
### Activation lifecycle
|
||||
|
||||
The public lifecycle has three states and no `queued` state:
|
||||
The internal residency lifecycle has three conditions and no separate `queued` state:
|
||||
|
||||
```text
|
||||
running
|
||||
@@ -89,11 +89,11 @@ The Agent inbox is the only queue. Every continuation message uses `Agent.follow
|
||||
|
||||
Routing depends only on Activation residency:
|
||||
|
||||
| Activation state | Sender | `followup` |
|
||||
|---|---|---|
|
||||
| `running` | parent or user | enqueue in the same Activation |
|
||||
| `waiting` | parent or user | wake the same Activation |
|
||||
| no Activation | parent or user | cold-resume a new Activation |
|
||||
| Activation state | `followup` |
|
||||
|---|---|
|
||||
| `running` | enqueue in the same Activation |
|
||||
| `waiting` | wake the same Activation |
|
||||
| no Activation | cold-resume a new Activation |
|
||||
|
||||
The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `MessageId`, while delivery failure throws. Existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations; adapters may render a generic acceptance but do not expose `started`, `queued`, `resumed`, or another subagent-specific route vocabulary.
|
||||
|
||||
@@ -103,13 +103,11 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`.
|
||||
|
||||
When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph.
|
||||
|
||||
Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`; a user-resumed child with no live owner has nothing to release. Manager teardown uses the same child-first order.
|
||||
|
||||
A user cold-resume creates an Activation without adding it to the historical parent's `ownedChildren`. If the direct parent later submits work to that live Activation and is itself continuation-managed, admission establishes ownership before enqueueing the message; a non-continuation parent remains outside the waiting graph.
|
||||
Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the final durability checkpoint settles, and the child's `AgentHandle` completes disposal. The manager calls `ctx.sessions.flush(child.session)`: `true` confirms durability, while `false` or rejection is normalized to `DURABILITY_FAILED`. A failed checkpoint is reported but does not prevent handle disposal or ownership release, because retaining a failed child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order.
|
||||
|
||||
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 and includes user-resumed Activations without live owners.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -121,21 +119,21 @@ A later proposal may add an ordinary model-facing `report(output)` tool that can
|
||||
|
||||
### Deferred steering
|
||||
|
||||
This version exposes no subagent steering operation. Parent and user continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam.
|
||||
This version exposes no subagent steering operation. Parent continuation messages always open later FIFO turns, so the continuation layer stores no current-turn controller and adds no controller-aware Agent admission seam.
|
||||
|
||||
A later host UI may expose separate **Steer** and **Follow up** actions. User steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design because distinct tool names express intent but do not establish whether the parent may modify a user-controlled turn.
|
||||
A later host UI may expose separate **Steer** and **Follow up** actions. Host steering would be strict and live-only: it may call the existing Agent steering path only while the Activation accepts a next step, must reject otherwise, and must never fall back to queueing or cold resume. Exposing parent steering to a model-facing tool remains a separate design.
|
||||
|
||||
### Authority and provenance
|
||||
|
||||
Authority is supplied by a trusted host interaction or an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority.
|
||||
Authority is supplied by an exact live Agent tool context. `MessageSource` and `senderSessionId` are durable provenance after admission, not caller-controlled authority.
|
||||
|
||||
This version authorizes the host user and the durable child's direct parent. Parent authorization checks `SessionHeader.parentSession` against the authenticated parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, teams, and workflows remain rejected until an explicit authority protocol exists.
|
||||
This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent before registering the child in that parent's `ownedChildren`. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol.
|
||||
|
||||
User authority may cold-resume a child without its parent. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship.
|
||||
Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship.
|
||||
|
||||
### Durability, disposal, and recovery
|
||||
|
||||
Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, neither parent nor user can cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service.
|
||||
Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service.
|
||||
|
||||
Host and manager teardown remains the lifecycle-wide stop path. It closes admission, disposes every live Activation forest child-first, and preserves the durable Sessions.
|
||||
|
||||
@@ -149,7 +147,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo
|
||||
|
||||
This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior.
|
||||
|
||||
It adds no subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged.
|
||||
It adds no host-user continuation, subagent steering operation, report tool, child-to-parent content delivery, automatic parent wakeup, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, public residency query, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -159,7 +157,7 @@ It adds no subagent steering operation, report tool, child-to-parent content del
|
||||
|
||||
**Dispose the Agent while waiting.** Reconstructing a parent while its child still belongs to the previous process-local ownership graph would require a durable ownership and teardown protocol. Retaining the `AgentHandle` only for the unfinished graph preserves child-first teardown without keeping settled history resident.
|
||||
|
||||
**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no shipped behavior to own and would complicate user cold resume with an unnecessary live-parent input.
|
||||
**Let the provider create, resume, or deliver through an Agent handle.** Initial providers own only `prepareContinuable()` and its detached creation-spec distinction: whether a child begins fresh or with a parent prefix. The manager must call `ctx.agents.create()` through its private activation-owner scope so that scope is a structural owner of every handle. A persisted in-process Session already contains the initial prefix and generic reconstruction descriptor, while delivery belongs to the Agent inbox. Giving providers any later handle, `SubagentRun`, or message ownership would preserve a seam with no shipped behavior to own.
|
||||
|
||||
**Add report delivery now.** A repeatable model-facing tool is compatible with this lifecycle, but quiet versus waking delivery, recipient selection, acknowledgement, durability, and retry behavior are independent product choices. Deferring the tool keeps the first version focused on conversation admission and residency without constraining that later policy.
|
||||
|
||||
@@ -167,9 +165,11 @@ It adds no subagent steering operation, report tool, child-to-parent content del
|
||||
|
||||
**Retain the exact parent Agent in a separate link.** The parent Activation already owns its `AgentHandle`, and `ownedChildren` prevents that Activation from disposing while the child remains live. Resolving the parent by Session id is therefore sufficient and avoids a redundant runtime reference.
|
||||
|
||||
**Maintain a separate queue for parent messages.** A second FIFO creates ambiguous ordering against user messages already accepted by the Agent. A single Agent inbox gives both origins one observable order.
|
||||
**Maintain a separate queue for continuation messages.** A second FIFO creates ambiguous ordering against messages already accepted by the Agent. A single Agent inbox gives every accepted turn one observable order.
|
||||
|
||||
**Expose subagent steering now.** User steering can be a strict live-only host action, but parent steering needs current-turn controller state to protect a user-controlled turn. Queueing every first-version continuation avoids that state and its admission race. A later UI can add a distinct user-only action without changing follow-up ordering.
|
||||
**Expose subagent steering now.** Parent steering needs current-turn controller state and a separate admission policy from follow-up delivery. Queueing every first-version continuation avoids that state and its admission race.
|
||||
|
||||
**Expose host-user follow-up without a host consumer.** A public authority-minting method and user branch would make cold resume possible without the historical parent, but no production host adapter calls that operation. The seam accepts only the exact live parent until a concrete authenticated host interaction can receive a private capability.
|
||||
|
||||
**Return a subagent-specific delivery route.** Labels such as `started`, `queued`, and `resumed` duplicate Activation and inbox state without giving the caller an independent result. Reusing `MessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it.
|
||||
|
||||
@@ -185,9 +185,8 @@ The implementation pins these behaviors:
|
||||
- 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.
|
||||
- A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`.
|
||||
- A user can cold-resume a persisted child without loading its historical parent.
|
||||
- `followup()` accepts only trusted parent or user authority; durable message provenance cannot authorize delivery.
|
||||
- Parent and user continuation messages always use `Agent.followup()` and share its inbox FIFO, including when one origin queues behind the other or the child already has an open turn.
|
||||
- `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.
|
||||
- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result.
|
||||
- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host and manager teardown retains child-first global cleanup.
|
||||
- This version exposes no subagent steering operation or current-turn controller state.
|
||||
@@ -201,7 +200,7 @@ The implementation pins these behaviors:
|
||||
- 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 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, mixed parent/user follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal.
|
||||
- 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.
|
||||
|
||||
### Accepted costs
|
||||
|
||||
@@ -213,6 +212,6 @@ The process-local inbox and ownership graph do not coordinate two harness proces
|
||||
|
||||
Without report delivery, completing a child turn neither sends its content to nor wakes the historical parent. The output remains in the durable child Session until a caller inspects that transcript or submits another authorized turn. A later report tool may add quiet or waking delivery without changing the Activation lifecycle.
|
||||
|
||||
Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later user-only UI steering action may reduce that latency without introducing parent-versus-user controller policy here.
|
||||
Queueing every continuation message means a parent cannot correct an in-progress child turn immediately; the correction runs as the next turn. A later UI steering action may reduce that latency without changing follow-up ordering.
|
||||
|
||||
A failed final durability checkpoint allows the runtime ownership graph to drain but leaves the persisted child state missing or stale. The failure is observable as `DURABILITY_FAILED`; retry and repair require a separate recovery design.
|
||||
+28
-29
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
以前的继续执行管理器让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这曾使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。
|
||||
|
||||
如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把两种消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。
|
||||
如果继续执行管理器为继续执行请求排队,而 Agent 保留自己的 inbox,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把所有消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。
|
||||
|
||||
运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。
|
||||
|
||||
用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。
|
||||
parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以保留唯一的排序规则。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -44,7 +44,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
|
||||
|
||||
`SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。
|
||||
|
||||
`ctx.subagents.followup(authority, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。`authority` 可以是 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`;parent 变体仅能从确切的在线 Agent 工具上下文通过准入,只有可信宿主适配器才能提供用户权限。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。
|
||||
`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。
|
||||
|
||||
对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。
|
||||
|
||||
@@ -52,13 +52,13 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
|
||||
|
||||
会话持有稳定的 child 身份、transcript(文本记录)、直接 parent 谱系、委派深度和带版本的继续执行描述符。`SessionHeader.parentSession` 是持久化来源信息和鉴权输入;它不是在线路由能力,也不表示历史 parent 仍然驻留。
|
||||
|
||||
空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。
|
||||
空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。冷恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。
|
||||
|
||||
激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。
|
||||
|
||||
### 激活生命周期
|
||||
|
||||
公开生命周期只有 3 个状态,没有 `queued` 状态:
|
||||
内部驻留生命周期有三个条件,没有单独的 `queued` 状态:
|
||||
|
||||
```text
|
||||
running
|
||||
@@ -89,11 +89,11 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
|
||||
|
||||
路由只取决于激活的驻留状态:
|
||||
|
||||
| 激活状态 | 发送方 | `followup` |
|
||||
|---|---|---|
|
||||
| `running` | parent 或 user | 在同一激活中排队 |
|
||||
| `waiting` | parent 或 user | 唤醒同一激活 |
|
||||
| 无激活 | parent 或 user | 冷恢复新激活 |
|
||||
| 激活状态 | `followup` |
|
||||
|---|---|
|
||||
| `running` | 在同一激活中排队 |
|
||||
| `waiting` | 唤醒同一激活 |
|
||||
| 无激活 | 冷恢复新激活 |
|
||||
|
||||
继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `MessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。
|
||||
|
||||
@@ -103,13 +103,11 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
|
||||
|
||||
当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
|
||||
|
||||
只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id;由用户恢复且没有在线 owner 的 child 则没有需要释放的所有权记录。管理器拆卸使用相同的 child-first 顺序。
|
||||
|
||||
用户冷恢复会创建一次激活,但不会将其加入历史 parent 的 `ownedChildren`。如果直接 parent 随后向这个在线激活提交工作,且该 parent 自身由继续执行管理器管理,准入过程会在消息入队前建立所有权;非继续执行 parent 仍位于等待图之外。
|
||||
只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、最终持久性检查点结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会调用 `ctx.sessions.flush(child.session)`:只有 `true` 确认持久性,`false` 或 rejection 则统一报告为 `DURABILITY_FAILED`。检查点失败会被报告,但不会阻止 handle dispose 或释放所有权,因为保留失败的 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。
|
||||
|
||||
系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。
|
||||
|
||||
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。
|
||||
顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 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 会话。
|
||||
|
||||
@@ -121,21 +119,21 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
|
||||
### 延后的 steering(中途引导)
|
||||
|
||||
本版本不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。
|
||||
本版本不暴露 subagent steering 操作。parent 的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。
|
||||
|
||||
后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。
|
||||
后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。宿主 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计。
|
||||
|
||||
### 权限与来源
|
||||
|
||||
权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。
|
||||
权限来自确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。
|
||||
|
||||
本版本授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。
|
||||
本版本只授权持久化 child 的直接 parent。管理器会根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。
|
||||
|
||||
用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。
|
||||
由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。
|
||||
|
||||
### 持久性、dispose 与恢复
|
||||
|
||||
没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。
|
||||
没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。
|
||||
|
||||
宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。
|
||||
|
||||
@@ -149,7 +147,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
|
||||
本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。
|
||||
|
||||
它不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。
|
||||
它不新增 host-user 继续执行、subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -159,7 +157,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
|
||||
**等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。
|
||||
|
||||
**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。
|
||||
**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam。
|
||||
|
||||
**现在就增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。
|
||||
|
||||
@@ -167,9 +165,11 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
|
||||
**在单独的 link 中保留确切的 parent Agent。** parent 激活已经持有自身 `AgentHandle`,而且 `ownedChildren` 会在 child 仍然在线时阻止该激活 dispose。因此,通过会话 id 解析 parent 已经足够,也可以避免冗余的运行时引用。
|
||||
|
||||
**为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。
|
||||
**为继续执行消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受消息之间顺序不明确。单个 Agent inbox 为每个已接受轮次提供唯一且可观察的顺序。
|
||||
|
||||
**现在就暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。
|
||||
**现在就暴露 subagent steering。** parent steering 需要当前轮次控制方状态,以及不同于 follow-up 投递的单独准入策略。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。
|
||||
|
||||
**在没有 host 消费方的情况下暴露 host-user follow-up。** 公开的权限铸造方法和用户分支可以在没有历史 parent 的情况下实现冷恢复,但没有生产 host 适配器调用该操作。在具体的经认证宿主交互能够收到私有能力之前,该 seam 只接受确切的在线 parent。
|
||||
|
||||
**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。
|
||||
|
||||
@@ -185,9 +185,8 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。
|
||||
- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。
|
||||
- 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。
|
||||
- 用户可以在不加载历史 parent 的前提下冷恢复持久化 child。
|
||||
- `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。
|
||||
- Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。
|
||||
- `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。
|
||||
- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。
|
||||
- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。
|
||||
- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。
|
||||
- 本版本不暴露 subagent steering 操作或当前轮次控制方状态。
|
||||
@@ -201,7 +200,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
- 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
|
||||
- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。
|
||||
- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。
|
||||
- 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。
|
||||
- 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。
|
||||
|
||||
### 已接受的代价
|
||||
|
||||
@@ -213,6 +212,6 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
|
||||
|
||||
没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。
|
||||
|
||||
将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在此引入 parent 与用户之间的控制方策略。
|
||||
将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续 UI steering 操作可以缩短该延迟,而不改变 follow-up 排序。
|
||||
|
||||
最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。
|
||||
@@ -794,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -811,7 +811,7 @@ A provider became resolvable in the registry.
|
||||
|
||||
Types: [SubagentProvider](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:117`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:110`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:116`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -848,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:127`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
|
||||
@@ -1967,35 +1967,18 @@ async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>
|
||||
* 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, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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
|
||||
async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>
|
||||
|
||||
/**
|
||||
* Close continuable admission synchronously, then dispose every live
|
||||
@@ -2040,9 +2023,9 @@ list(): string[]
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Types: [ActivationState](../core-data-structures/subagent.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentAuthority](../core-data-structures/subagent.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:176`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.subprocess` — `SubprocessService` (abstract seam)
|
||||
|
||||
|
||||
@@ -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: ceff3586bf6724bd6f47b71e9fb737361a2830f8
|
||||
subagent.zh.md: aa39ea382fe1e2a52b6ee794cfa71d8abc945da3
|
||||
subagent.md: 81d09903bec3bd4767e73720be4a8d58c7837eb4
|
||||
subagent.zh.md: 6fd6845b5ceedd81e02365680a534e6d0c726aaf
|
||||
@@ -99,7 +99,7 @@ Providers receive exactly this request: one-shot delegation has no service-resol
|
||||
|
||||
## Continuable children and activations
|
||||
|
||||
A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, authority, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper.
|
||||
A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper.
|
||||
|
||||
```text
|
||||
persisted Session
|
||||
@@ -113,17 +113,17 @@ persisted Session
|
||||
|
||||
`SubagentService.followup()` is the sole continuation-message operation, and routing depends only on Activation residency:
|
||||
|
||||
| Activation state | Sender | `followup` |
|
||||
|---|---|---|
|
||||
| `running` | parent or user | enqueue in the same Activation |
|
||||
| `waiting` | parent or user | wake the same Activation |
|
||||
| no Activation | parent or user | cold-resume a new Activation |
|
||||
| Activation state | `followup` |
|
||||
|---|---|
|
||||
| `running` | enqueue in the same Activation |
|
||||
| `waiting` | wake the same Activation |
|
||||
| no Activation | cold-resume a new Activation |
|
||||
|
||||
`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the `AgentHandle` and removes the Activation. The manager derives these from Agent quiescence and the owned-child set rather than maintaining a second execution state machine, and `activationState()` reports the current value (`undefined` when no Activation is live).
|
||||
`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the `AgentHandle` and removes the Activation. The manager derives these internal conditions from Agent quiescence and the owned-child set rather than maintaining a second execution state machine.
|
||||
|
||||
The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so parent and user messages share one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route.
|
||||
The Agent inbox is the only queue. Every continuation message becomes one `Agent.followup()` FIFO turn, so accepted messages have one observable order and a follow-up cannot redirect a turn already underway. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/enqueue`, `agent/inbox/dequeue`, and `agent/inbox/discard` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route.
|
||||
|
||||
Authority is supplied by a trusted host interaction or an exact live Agent tool context. The parent variant is admitted only when the authenticated Agent is the durable child's direct parent recorded in `SessionHeader.parentSession`. User authority carries an opaque grant that only `SubagentService.userAuthority()` mints, so a caller cannot claim it by writing the discriminant — a plugin holding `ctx.subagents`, including model-generated mount code, would otherwise bypass the direct-parent check for any known child id. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority — the optional model-facing tool uses `CoordinatorMessageSource`, while a host adapter uses `{ kind: 'user' }`. User authority may cold-resume a child without loading its historical parent.
|
||||
Follow-up authority comes from an exact live Agent tool context. The authenticated Agent must be the durable child's direct parent recorded in `SessionHeader.parentSession`. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority; the optional model-facing tool uses `CoordinatorMessageSource`.
|
||||
|
||||
For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation.
|
||||
|
||||
@@ -140,25 +140,6 @@ interface CoordinatorMessageSource {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Options for following up with one continuable child. */
|
||||
interface SubagentFollowupOptions {
|
||||
@@ -179,18 +160,6 @@ interface ContinuableStart {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The public 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.
|
||||
*/
|
||||
type ActivationState = 'running' | 'waiting' | 'settled'
|
||||
```
|
||||
|
||||
The provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its returned spec carries only detached provider-specific creation inputs — today the optional parent-history seed — and no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. Cold resume does not dispatch through a provider at all: the manager folds the generic descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, and submits the waiting turn.
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -99,7 +99,7 @@ interface SubagentStartRequest {
|
||||
|
||||
## 可继续子 agent 与激活
|
||||
|
||||
**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。
|
||||
**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。
|
||||
|
||||
```text
|
||||
persisted Session
|
||||
@@ -113,17 +113,17 @@ persisted Session
|
||||
|
||||
`SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态:
|
||||
|
||||
| Activation 状态 | 发送方 | `followup` |
|
||||
|---|---|---|
|
||||
| `running` | parent 或 user | 在同一 Activation 中入队 |
|
||||
| `waiting` | parent 或 user | 唤醒同一 Activation |
|
||||
| 无 Activation | parent 或 user | 冷恢复一个新的 Activation |
|
||||
| Activation 状态 | `followup` |
|
||||
|---|---|
|
||||
| `running` | 在同一 Activation 中入队 |
|
||||
| `waiting` | 唤醒同一 Activation |
|
||||
| 无 Activation | 冷恢复一个新的 Activation |
|
||||
|
||||
`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。
|
||||
`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些内部条件,而非维护第二套执行状态机。
|
||||
|
||||
Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。
|
||||
Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此已接受的消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 与 `agent/inbox/discard` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。
|
||||
|
||||
授权由受信任的宿主交互或一个确切的实时 Agent 工具上下文提供。仅当已认证的 Agent 是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级时,才会准入 parent 变体;只有受信任的宿主适配器才能提供 user 授权。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限——可选的面向模型工具使用 `CoordinatorMessageSource`,宿主适配器则使用 `{ kind: 'user' }`。user 授权可以在不加载子 agent 历史父级的情况下冷恢复它。
|
||||
后续操作的权限来自确切的在线 Agent 工具上下文。已认证的 Agent 必须是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限;可选的面向模型工具使用 `CoordinatorMessageSource`。
|
||||
|
||||
对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。
|
||||
|
||||
@@ -140,25 +140,6 @@ interface CoordinatorMessageSource {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Options for following up with one continuable child. */
|
||||
interface SubagentFollowupOptions {
|
||||
@@ -179,18 +160,6 @@ interface ContinuableStart {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The public 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.
|
||||
*/
|
||||
type ActivationState = 'running' | 'waiting' | 'settled'
|
||||
```
|
||||
|
||||
提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。
|
||||
|
||||
```ts type-equiv
|
||||
|
||||
@@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:117`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:123`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:110`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:116`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:127`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,但丢失的消息不会自动重放。
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
Generated
-6
@@ -5196,12 +5196,6 @@ importers:
|
||||
'@deepseek-ai/dsh-tasks':
|
||||
specifier: workspace:^
|
||||
version: link:../../tasks/tasks
|
||||
'@deepseek-ai/dsh-tasks-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../tasks/tasks-local
|
||||
'@deepseek-ai/dsh-tool-tasks':
|
||||
specifier: workspace:^
|
||||
version: link:../../tasks/tool-tasks
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
@@ -160,13 +160,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
ActivationState: 'subagent.md',
|
||||
ContinuableCreateRequest: 'subagent.md',
|
||||
ContinuableCreateSpec: 'subagent.md',
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentAuthority: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentRun: 'subagent.md',
|
||||
@@ -286,8 +284,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
|
||||
@@ -1104,21 +1104,11 @@
|
||||
"symbol": "SubagentFollowupOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentAuthority",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableStart",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ActivationState",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableCreateRequest",
|
||||
|
||||
Reference in New Issue
Block a user