From 26a117f842c9cc87987963a2cd127b2c980e283c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:26:13 +0800 Subject: [PATCH 01/66] feat(subagent): activation-based continuable subagents (source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Task-backed continuation manager with one durable Session plus at most one process-local Activation — a residency epoch for a reconstructed child Agent, not a request, result, cancellation, or Task boundary. The manager owns activation admission, authority, the live ownership graph, cold resume, and child-first disposal; the Agent inbox is the only turn FIFO. - startContinuable() is async and returns { childId, messageId } at inbox acceptance; followup() takes a SubagentAuthority and returns AgentMessageId. - SubagentProvider.resume?(), SubagentProviderResumeRequest, SubagentRun.steer?(), SubagentProviderStartRequest and SubagentContinuation are deleted; prepareContinuable?() is the continuable-creation capability. - Cold resume calls ctx.agents.resume() from the manager through a private activation-owner scope, never dispatching through a provider. - Extract shared child composition, descriptor seeding, depth accounting, and one-shot run settlement so the manager and one-shot driver keep one home per fact. Tests and docs follow in subsequent commits. --- ...ntinuable-subagent-conversations.i18n.yaml | 6 + ...7-28-continuable-subagent-conversations.md | 216 ++++ ...8-continuable-subagent-conversations.zh.md | 216 ++++ packages/subagent/subagent-fork/src/index.ts | 20 +- .../subagent/subagent-inprocess/src/index.ts | 220 +--- packages/subagent/subagent-spawn/src/index.ts | 16 +- packages/subagent/subagent/src/child-agent.ts | 128 ++ .../subagent/subagent/src/continuation.ts | 1114 ++++++++++------- packages/subagent/subagent/src/depth.ts | 51 + .../subagent/subagent/src/descriptor-seed.ts | 31 + packages/subagent/subagent/src/index.ts | 254 ++-- .../subagent/subagent/src/run-settlement.ts | 71 ++ packages/subagent/subagent/src/types.ts | 162 +-- .../tool-subagent-control/src/index.ts | 37 +- packages/subagent/tool-subagent/src/index.ts | 47 +- 15 files changed, 1721 insertions(+), 868 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md create mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md create mode 100644 packages/subagent/subagent/src/child-agent.ts create mode 100644 packages/subagent/subagent/src/depth.ts create mode 100644 packages/subagent/subagent/src/descriptor-seed.ts create mode 100644 packages/subagent/subagent/src/run-settlement.ts diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml new file mode 100644 index 0000000000..4ef20ef978 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md +2026-07-28-continuable-subagent-conversations.md: 3902fbc33004219f98d070d4b898de6b2c19d40d +2026-07-28-continuable-subagent-conversations.zh.md: 11f59d8f1a57e2d1bf375a3e1c1cd46043c60a3f diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md new file mode 100644 index 0000000000..3902fbc330 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md @@ -0,0 +1,216 @@ +# Agent Note: Continuable subagents + +Status: proposed + +English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) + +This proposal would replace the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). + +## Problem + +The continuation manager currently makes one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposes the child Agent, Task completion injects the completion notice, and later input reconstructs another Agent. This couples 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 creates two FIFOs with no single ordering authority. Giving both to Tasks instead duplicates 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. + +## Proposal + +A continuable subagent has one durable Session and at most one process-local Activation: + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations +``` + +An Activation is one residency epoch for a reconstructed child Agent. It may execute multiple FIFO turns and remain resident while waiting for descendants. It is not a request, result, cancellation, or Task boundary. + +The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. The proposal creates no Task for a continuable subagent, no Activation FIFO, and no queued Activation state. + +### Materialization and public operations + +The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `AgentMessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. + +Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. + +`backgroundMode: 'one-shot' | 'continuable'` remains deployment policy. Configured continuable mode requires `prepareContinuable`; method presence replaces `SubagentProvider.resume?()` as the capability check, while a capable provider may still run one-shot work. + +Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent, and the initial provider name is not a recovery capability; remote providers require a separate design. + +`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 `AgentMessageId`, 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 `AgentMessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. + +### Durable Session and live Activation + +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. + +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 outside the MVP 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: + +```text +running + | Agent quiescent with live children + v +waiting + | next-turn + +--------------------------> running + +running or waiting + | Agent quiescent and no live children + v +settled + | AgentHandle.dispose completes + v +no Activation +``` + +`running` means the Agent has an active admission or turn, or its inbox contains waking work. `waiting` means the Agent is quiescent but the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation. + +The manager derives these states from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A `next-turn` delivered while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation. + +The manager linearizes delivery, child release, and disposal for each durable child. If a delivery races with final disposal, exactly one side wins the admission cutoff: delivery either enters the still-live Agent inbox, or waits for disposal and cold-resumes a new Activation. No caller can send to a handle after its disposal transaction begins. + +### One inbox and follow-up delivery + +The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every accepted waking item keeps the current Activation live until `Agent.whenIdle()` observes the complete waking suffix. + +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 | + +The continuation layer defines no separate delivery-route result. Successful `ctx.subagents.followup()` and `send_message` delivery returns the accepted `AgentMessageId`, 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. + +### Child ownership + +Every Activation owns its `AgentHandle` and an `ownedChildren: Set`. Because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. `SessionHeader.parentSession` records the durable direct-parent identity, while membership in `ownedChildren` records the process-local ownership relationship. + +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. + +The MVP retains ownership 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. + +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. + +### Deferred report delivery + +The MVP exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. + +A later proposal may add an ordinary model-facing `report(output)` tool that can be called zero or multiple times in one turn. Its delivery policy may distinguish quiet parent injection from waking the parent; recipient selection, acknowledgement, durability, and retry semantics are deferred with that tool. Adding report delivery does not require another Activation state or execution queue. + +### Deferred steering + +The MVP 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. + +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. + +### 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. + +The MVP 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. + +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. + +### 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 MVP 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. + +Each turn requests the Session durability checkpoint, and final Activation settlement requires the manager to inspect `ctx.sessions.flush()` rather than ignore its boolean result. `true` confirms that at least one durability listener participated and every listener settled successfully. `false` or rejection reports `DURABILITY_FAILED`; normal background settlement logs the lifecycle failure, while an explicit host or manager drain includes it in the aggregate rejection after all branches settle. Either way, the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. + +Only messages written to the child Session log are reconstructable with their admitted provenance; inbox acceptance alone provides no restart guarantee. + +Session and descriptor persistence survive restart. Activation state, Agent inbox contents, and the ownership graph are process-local. A process crash may lose an accepted initial prompt or follow-up that remained in the inbox without reaching the Session log. The Session and descriptor may survive so a later authorized message can cold-resume the child, but the lost message is not replayed automatically. Recovering accepted unfinished or unlogged messages requires a durable inbox protocol and is not implied here. + +### Scope + +The MVP 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. + +The MVP 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. + +## Alternatives considered + +**Keep Task-backed Activations.** Tasks provide generic status, result collection, and cancellation, but using them for conversation delivery creates a second queue and duplicates turn ownership. The proposal gives up those generic Task controls so the Agent inbox remains the only execution order. + +**Create one Activation per `next-turn`.** This restores independent result and cancellation boundaries, but it requires a manager FIFO beside the Agent inbox and makes a retained Agent cross artificial Activation boundaries. One Activation per residency epoch is smaller and follows the `AgentHandle` lifetime directly. + +**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 MVP behavior to own and would complicate user cold resume with an unnecessary live-parent input. + +**Add report delivery to the MVP.** 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. + +**Treat `SessionHeader.parentSession` as live ownership.** Durable lineage does not prove that the historical parent currently owns the child. Membership in the live parent's `ownedChildren` records the process-local relationship without changing durable provenance. + +**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. + +**Expose subagent steering in the MVP.** 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. + +**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 `AgentMessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. + +**Use a child reference count.** A count cannot identify which child still owns teardown work and permits duplicate decrement errors. An identity set retains cancellation and disposal obligations explicitly. + +## Acceptance criteria + +- A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. +- `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. +- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `AgentMessageId`, without waiting for turn start or a Session-log write. +- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. +- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. +- 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. +- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `AgentMessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. +- The MVP 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. +- The MVP exposes no subagent steering operation or current-turn controller state. +- An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. +- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. +- Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. +- Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. +- Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- The MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. +- Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. +- No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the residency-only routing table, single-inbox ordering, `AgentMessageId` 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. + +## Risks + +Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. + +Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but the MVP adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. + +The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. + +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 into the MVP. + +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. diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md new file mode 100644 index 0000000000..11f59d8f1a --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -0,0 +1,216 @@ +# Agent Note(agent 决策记录):可继续的 subagent + +Status: proposed + +[English](2026-07-28-continuable-subagent-conversations.md) | 中文 + +本提案将取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。提案保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 + +## 问题 + +继续执行管理器目前让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 + +如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。如果两种消息都交给 Task,系统又会重复 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 + +运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。 + +用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。 + +## 提案 + +一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations +``` + +激活是重建 child Agent 的一次驻留周期。它可以执行多个 FIFO 轮次,并在等待后代时保持驻留。它不是请求、结果、取消或 Task 边界。 + +继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。本提案不会为可继续 subagent 创建 Task、激活 FIFO 或 queued 激活状态。 + +### 物化与公开操作 + +具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `AgentMessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 + +inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 + +`backgroundMode: 'one-shot' | 'continuable'` 仍是部署策略。配置为 continuable 时要求存在 `prepareContinuable`;该方法是否存在会取代 `SubagentProvider.resume?()` 成为能力检查,而具备该能力的提供方仍可运行 one-shot 工作。 + +冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在,初始提供方名称也不是恢复能力;远程提供方需要单独设计。 + +`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 都返回已接受的 `AgentMessageId`,两者都不报告管理器如何物化激活。 + +对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `AgentMessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 + +### 持久化会话与在线激活 + +会话持有稳定的 child 身份、transcript(文本记录)、直接 parent 谱系、委派深度和带版本的继续执行描述符。`SessionHeader.parentSession` 是持久化来源信息和鉴权输入;它不是在线路由能力,也不表示历史 parent 仍然驻留。 + +空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 + +激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在 MVP 范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 + +### 激活生命周期 + +公开生命周期只有 3 个状态,没有 `queued` 状态: + +```text +running + | Agent quiescent with live children + v +waiting + | next-turn + +--------------------------> running + +running or waiting + | Agent quiescent and no live children + v +settled + | AgentHandle.dispose completes + v +no Activation +``` + +`running` 表示 Agent 正在执行准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。 + +管理器根据 Agent 是否完全停稳以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时投递的 `next-turn` 会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。 + +管理器会针对每个持久化 child,将投递、child 释放和 dispose 线性化。如果投递与最终 dispose 发生竞争,只有一方能越过准入截止点:投递要么进入仍在线的 Agent inbox,要么等待 dispose 完成后冷恢复新激活。任何调用方都不能向已经开始 dispose 事务的 handle 发送消息。 + +### 一个 inbox 与 follow-up 投递 + +Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个已接受且会唤醒 Agent 的条目都会让当前激活保持在线,直至 `Agent.whenIdle()` 观察到完整的唤醒工作后缀已经结束。 + +路由只取决于激活的驻留状态: + +| 激活状态 | 发送方 | `followup` | +|---|---|---| +| `running` | parent 或 user | 在同一激活中排队 | +| `waiting` | parent 或 user | 唤醒同一激活 | +| 无激活 | parent 或 user | 冷恢复新激活 | + +继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `AgentMessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 + +### child 所有权 + +每次激活都持有自身的 `AgentHandle` 和一个 `ownedChildren: Set`。由于一个会话至多有一次在线激活,child 会话 id 足以标识在线 child,无需另一个运行时 incarnation 引用。`SessionHeader.parentSession` 记录持久化的直接 parent 身份,`ownedChildren` 中的成员关系则记录进程内所有权关系。 + +当经过身份认证的 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 仍位于等待图之外。 + +MVP 会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 + +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。 + +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 会话。 + +### 延后的报告投递 + +MVP 不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 + +后续提案可以增加一个普通的面向模型 `report(output)` 工具;模型在一个轮次中可以调用它零次或多次。其投递策略可以区分静默注入 parent 与唤醒 parent;接收方选择、确认、持久性和重试语义均与该工具一并延后决定。增加报告投递无需引入另一个激活状态或执行队列。 + +### 延后的 steering(中途引导) + +MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 + +后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。 + +### 权限与来源 + +权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 + +MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 + +用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 + +### 持久性、dispose 与恢复 + +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本 MVP 不通过 subagent 服务暴露它。 + +宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 + +每个轮次都会请求执行会话持久性检查点,激活最终结算时,管理器必须检查 `ctx.sessions.flush()`,而不能忽略其布尔结果。`true` 确认至少有一个持久性 listener 参与,且所有 listener 都成功结算。`false` 或 rejection 会报告 `DURABILITY_FAILED`;普通后台结算会记录该生命周期失败,显式的宿主或管理器 drain 则会在所有分支结算后,将其纳入聚合 rejection。无论结果如何,管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 + +只有实际写入 child 会话日志的消息,才能根据其准入来源重建;仅被 inbox 接受并不提供重启保证。 + +会话和描述符的持久化状态可在重启后保留。激活状态、Agent inbox 内容和所有权图都是进程内状态。进程崩溃可能丢失已被接受但仍留在 inbox、尚未写入会话日志的初始提示词或 follow-up。会话和描述符可能保留,因此后续获得授权的消息仍可冷恢复 child,但丢失的消息不会自动回放。恢复已接受但未完成或未写入日志的消息需要持久化 inbox 协议,本提案不隐含该能力。 + +### 范围 + +MVP 覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 + +MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 + +## 曾考虑的替代方案 + +**保留由 Task 支撑的激活。** Task 可以提供通用状态、结果收集和取消,但使用 Task 投递会话会产生第二条队列,并重复轮次所有权。本提案放弃这些通用 Task 控制,让 Agent inbox 成为唯一执行顺序。 + +**每个 `next-turn` 创建一次激活。** 这会恢复独立的结果与取消边界,但需要在 Agent inbox 旁维护管理器 FIFO,还会使所保留的 Agent 跨越人为划分的激活边界。每个驻留周期对应一次激活更小,也直接跟随 `AgentHandle` 生命周期。 + +**等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。 + +**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有 MVP 行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 + +**在 MVP 中增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 + +**将 `SessionHeader.parentSession` 视为在线所有权。** 持久化谱系不能证明历史 parent 当前持有 child。在线 parent 的 `ownedChildren` 成员关系会记录进程内关系,而不改变持久化来源。 + +**在单独的 link 中保留确切的 parent Agent。** parent 激活已经持有自身 `AgentHandle`,而且 `ownedChildren` 会在 child 仍然在线时阻止该激活 dispose。因此,通过会话 id 解析 parent 已经足够,也可以避免冗余的运行时引用。 + +**为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。 + +**在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 + +**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `AgentMessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 + +**使用 child 引用计数。** 计数无法识别哪个 child 仍持有拆卸工作,也允许重复递减错误。身份集合会显式保留取消和 dispose 义务。 + +## 验收标准 + +- 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 +- `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 +- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `AgentMessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 +- 初始提示词被 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 已有开放轮次的情况。 +- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `AgentMessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 +- MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 +- 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 +- 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 +- 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 +- 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 +- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- MVP 不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 +- 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 +- 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `AgentMessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 +- 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 + +## 风险 + +移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 + +在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但 MVP 不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 + +进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 + +没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。 + +将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在 MVP 中引入 parent 与用户之间的控制方策略。 + +最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 8461795120..f4708dc14f 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -12,12 +12,13 @@ import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, + SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' // `tools` is deliberately NOT injected — same rationale as subagent-spawn: the @@ -64,7 +65,7 @@ class ForkProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentProviderStartRequest) { + start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed @@ -73,11 +74,12 @@ class ForkProvider implements SubagentProvider { }) } - resume(request: SubagentProviderResumeRequest) { - // Cold resume loads the child's OWN persisted transcript, which already - // contains the completed-turn prefix captured at initial creation; it - // never forks the parent's newer history again. - return resumeInProcessRun(request) + prepareContinuable(request: ContinuableCreateRequest): Promise { + // The fork prefix is captured ONCE, at creation: it becomes part of the + // child's own durable transcript, so a later cold resume replays that + // prefix instead of re-forking the parent's newer history. + const seed = completedTurnPrefix(request.parent) + return Promise.resolve(seed.length > 0 ? { seed } : {}) } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 38fd418f39..ddbcf8753e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -1,24 +1,32 @@ /** - * Shared driver for in-process subagent providers. The agent factory's + * Shared driver for in-process ONE-SHOT subagent providers. The agent factory's * creation transaction owns unpublished setup and rollback; after publication * the returned AgentHandle is the one quiescent lifecycle owner held by the * provider's caller. * + * Continuable children never come through here: the continuation manager + * composes and drives them directly, so this driver owns exactly one turn with + * one result. + * * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' +import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { + applyChildComposition, + assertSubagentMaxDepth, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, +} from '@deepseek-ai/dsh-subagent' import type { - SubagentDescriptorData, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, + SubagentStartRequest, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve @@ -36,14 +44,6 @@ export { STRUCTURED_OUTPUT_INSTRUCTION, } from './structured.ts' -/** Thrown when starting a child would exceed the requested depth cap. */ -class SubagentDepthError extends Error { - constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { - super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) - this.name = 'SubagentDepthError' - } -} - /** Map a session turn outcome to the subagent seam's terminal vocabulary. */ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { switch (reason?.kind) { @@ -67,76 +67,31 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } -/** Whether one activation must prove its final state durable before success. */ -type Durability = 'best-effort' | 'required' - -/** Activation-specific inputs to the shared in-process driver. */ -interface DriveTurnOptions { - readonly durability: Durability - /** Attribution for a resumed activation's follow-up prompt. */ - readonly source?: MessageSource - readonly structured?: StructuredAttachment -} - /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') } /** - * Register the one-shot child-scoped contribution that appends the durable - * `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper - * appends before downstream admission can block or throw. Allowed admission - * opens the initial turn afterward; the final required checkpoint also - * persists the descriptor when no turn opens. - */ -function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { - childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => { - agent.session.append('subagent/descriptor', descriptor) - return next() - }, { prepend: true }) -} - -/** - * Establish and drive one in-process child. Fulfillment means the agent is - * already published in the registry; rejection means the agent factory's + * Establish and drive one in-process one-shot child. Fulfillment means the agent + * is already published in the registry; rejection means the agent factory's * creation transaction and any partially-created child have reached quiescence. - * A `request.continuation` publishes exactly its stable child id and appends - * its descriptor before the child's initial prompt admission. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. */ export async function startInProcessRun( - request: SubagentProviderStartRequest, + request: SubagentStartRequest, options: InProcessRunOptions, ): Promise { assertSubagentMaxDepth(request.maxDepth) if (request.signal.aborted) throw prePublicationAbort() const parent = request.parent - const childDepth = delegationDepthOf(parent) + 1 - if (!Number.isSafeInteger(childDepth)) { - throw new RangeError('subagent child depth exceeds the safe-integer range') - } - if (request.maxDepth !== undefined && childDepth > request.maxDepth) { - throw new SubagentDepthError(childDepth, request.maxDepth) - } + const childDepth = resolveChildDepth(parent, request.maxDepth) - // A continuable delegation names the durable conversation up front; the - // provider publishes exactly that id instead of allocating one internally. - const childId = request.continuation?.sessionId ?? SessionId(randomUUID()) - const seedLength = options.seed?.length ?? 0 - const parentHeader = parent.session.header - const parentProvider = parent.options.provider - const parentModel = parent.options.model - const parentMaxTokens = parent.options.maxTokens - const agentOptions: AgentOptions = { - ...parentProvider !== undefined ? { provider: parentProvider } : {}, - ...parentModel !== undefined ? { model: parentModel } : {}, - ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, - ...request.agentOptions, - subagentDepth: childDepth, - } + const childId = SessionId(randomUUID()) + const seed = options.seed + const activationBoundary = seed?.length ?? 0 // Capture before the first await: a later parent switch belongs to the // parent's future. @@ -145,6 +100,8 @@ export async function startInProcessRun( let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { + // Inherited overrides land on the child's own log, so its effective policy + // is reconstructable from that log alone. const childSession = (childCtx.agent as Agent).session if (inheritedMode !== undefined) { childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' }) @@ -152,29 +109,20 @@ export async function startInProcessRun( if (inheritedPolicy !== undefined) { childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) } - if (request.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona }) - } - if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter) + applyChildComposition(childCtx, { + persona: request.persona, + toolFilter: request.toolFilter, + }) if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } - if (request.continuation !== undefined) { - attachDescriptorAppend(childCtx, request.continuation.descriptor) - } } const handle = await parent.ctx.agents.create({ sessionId: childId, - meta: { - ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, - parentSession: parentHeader.id, - // Durable: the recursion budget must survive persistence and resume. - delegationDepth: childDepth, - ...seedLength > 0 ? { seedLength } : {}, - }, - ...options.seed === undefined ? {} : { seed: options.seed }, - agentOptions, + meta: childSessionMeta(parent, childDepth, activationBoundary), + ...seed !== undefined ? { seed } : {}, + agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), signal: request.signal, setup, }) @@ -183,62 +131,15 @@ export async function startInProcessRun( request.signal, request.prompt, childId, - seedLength, - { - durability: request.continuation === undefined ? 'best-effort' : 'required', - ...structured === undefined ? {} : { structured }, - }, + activationBoundary, + structured, ) } /** - * Reconstruct a persisted continuable child under the live parent's scope and - * drive one follow-up turn. The resumed session's own transcript is the seed - * (loaded through the parent's persistence-backed registry `resume`), so a - * fork child never re-forks current parent history; the persisted header - * remains authoritative for lineage and the delegation-depth floor. - * @param request - the fully resolved resume request from the continuation manager. - * @returns a fresh ready holder-owned run for this activation. - */ -export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise { - if (request.signal.aborted) throw prePublicationAbort() - const descriptor = request.descriptor - const agentOptions: AgentOptions = { - ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, - ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, - } - const setup = (childCtx: Context): void => { - if (descriptor.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona }) - } - if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter) - } - - const handle = await request.parent.ctx.agents.resume({ - resumeSessionId: request.sessionId, - agentOptions, - signal: request.signal, - setup, - }) - // The result boundary is this activation's own work: everything already in - // the resumed transcript belongs to earlier turns. - const resumePoint = handle.agent.session.events.length - return driveTurn( - handle, - request.signal, - request.prompt, - request.sessionId, - resumePoint, - { durability: 'required', source: request.source }, - ) -} - -/** - * Drive one activation turn on a published child and wrap it as a run. The - * caller has already created or resumed the agent; this owns the - * signal-handoff race, the live abort listener, result collection past - * `boundary`, the continuable-run durability confirmation, confirmed - * steering, and disposal. + * Drive one turn on a published child and wrap it as a run. The caller has + * already created the agent; this owns the signal-handoff race, the live abort + * listener, result collection past `boundary`, and disposal. */ function driveTurn( handle: AgentHandle, @@ -246,10 +147,9 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, - options: DriveTurnOptions, + structured: StructuredAttachment | undefined, ): SubagentRun | Promise { const child = handle.agent - const { durability, source, structured } = options // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. if (signal.aborted) { @@ -265,30 +165,13 @@ function driveTurn( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() - if (durability === 'required') { - try { - const participated = await child.ctx.sessions.flush(child.session) - if (!participated) { - throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`) - } - } catch (error: unknown) { - if (!signal.aborted) { - throw new SubagentError( - `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, - 'DURABILITY_FAILED', - { cause: error }, - ) - } - } - } return readResult( child, boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, - durability === 'required' && signal.aborted, ) } finally { signal.removeEventListener('abort', onAbort) @@ -304,23 +187,6 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - async steer(content: ContentBlock[], steeringSource: MessageSource): Promise { - // The status check and submission share one synchronous frame. An idle - // Agent.steer() would queue an untracked turn after this run's result. - if (child.status !== 'running') { - throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) - } - // Avoid waiting for the structured terminal checkpoint when its outcome - // is already authoritative and synchronously visible. - if (structured?.captured() !== undefined) { - throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) - } - const receipt = child.steer(createUserMessage({ content, source: steeringSource })) - const outcome = await receipt.outcome - if (outcome.status === 'rejected') { - throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`) - } - }, } } @@ -330,7 +196,6 @@ function readResult( boundary: number, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, - cancellationOwnsCompleted = false, ): SubagentResult { const own = child.session.events.slice(boundary) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') @@ -338,13 +203,8 @@ function readResult( const output: ContentBlock[] = lastMessage?.data.message.content ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary - // `aborted` end, yielding `disposed` instead. Activation cancellation during - // its final durability checkpoint also owns a recorded completed turn because - // the provider has not published that result yet. - const stopReason: SubagentStopReason = cancelled - && (recorded !== 'completed' || cancellationOwnsCompleted) - ? 'aborted' - : recorded + // `aborted` end, yielding `disposed` instead. + const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' ? 'aborted' : recorded if (structured !== undefined) { if (structured.captured !== undefined) { return { output, structured: structured.captured.value, stopReason } diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 0080c31521..7dceeac2ae 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -9,12 +9,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, + SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' // `tools` is deliberately not injected: the child factory already provides it during setup, @@ -45,17 +45,17 @@ class SpawnProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentProviderStartRequest) { + start(request: SubagentStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } - resume(request: SubagentProviderResumeRequest) { - // Cold resume reconstructs the persisted child from its own transcript - // under the live parent scope; the shared driver drives the follow-up turn. - return resumeInProcessRun(request) + prepareContinuable(): Promise { + // A spawned child starts fresh, so it contributes no seed; the continuation + // manager owns every later operation on it. + return Promise.resolve({}) } } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts new file mode 100644 index 0000000000..f93e5d5dff --- /dev/null +++ b/packages/subagent/subagent/src/child-agent.ts @@ -0,0 +1,128 @@ +/** + * Shared in-process child composition: the delegation-depth budget, the + * durable session metadata, the resolved child `AgentOptions`, and the scoped + * setup a child agent needs. Both the one-shot provider driver and the + * continuation manager compose children this way, so depth accounting and + * lineage stamping have one home. + * + * @module @deepseek-ai/dsh-subagent/child-agent + */ + +import type { Context } from 'cordis' +import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import { delegationDepthOf } from './depth.ts' + +/** Thrown when starting a child would exceed the requested depth cap. */ +export class SubagentDepthError extends Error { + constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { + super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) + this.name = 'SubagentDepthError' + } +} + +/** + * Resolve the child's delegation depth from its parent and enforce an optional + * cap. The persisted parent header is the monotone floor, so a resumed parent + * cannot delegate as if it were top-level. + * @param parent - the delegating parent agent. + * @param maxDepth - optional absolute cap the resolved depth must not exceed. + * @returns the child's non-negative safe-integer depth. + * @throws {SubagentDepthError} when the resolved depth exceeds `maxDepth`. + * @throws {RangeError} when the resolved depth leaves the safe-integer range. + */ +export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number { + const childDepth = delegationDepthOf(parent) + 1 + if (!Number.isSafeInteger(childDepth)) { + throw new RangeError('subagent child depth exceeds the safe-integer range') + } + if (maxDepth !== undefined && childDepth > maxDepth) { + throw new SubagentDepthError(childDepth, maxDepth) + } + return childDepth +} + +/** + * Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens + * route unless the request overrides it, stamped with the child's own + * delegation depth. + * @param parent - the delegating parent whose route the child inherits. + * @param requested - per-child overrides, if any. + * @param childDepth - the resolved delegation depth to stamp. + * @returns the resolved options for `ctx.agents.create()`. + */ +export function resolveChildAgentOptions( + parent: Agent, + requested: AgentOptions | undefined, + childDepth: number, +): AgentOptions { + const parentProvider = parent.options.provider + const parentModel = parent.options.model + const parentMaxTokens = parent.options.maxTokens + return { + ...parentProvider !== undefined ? { provider: parentProvider } : {}, + ...parentModel !== undefined ? { model: parentModel } : {}, + ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, + ...requested, + subagentDepth: childDepth, + } +} + +/** + * Build the child session's durable creation metadata: the parent's workspace, + * its direct lineage, the recursion budget that must survive persistence, and + * the seed boundary that separates inherited parent history from child work. + * @param parent - the delegating parent agent. + * @param childDepth - the resolved delegation depth to persist. + * @param lineageSeedLength - how many leading events came from the parent's log. + * @returns the `meta` for `ctx.agents.create()`. + */ +export function childSessionMeta( + parent: Agent, + childDepth: number, + lineageSeedLength: number, +): NonNullable { + const parentHeader = parent.session.header + return { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + // Durable: the recursion budget must survive persistence and resume. + delegationDepth: childDepth, + ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {}, + } +} + +/** The scoped composition a child agent's creation window applies. */ +export interface ChildComposition { + /** Per-child persona shadowing the deployment persona. */ + readonly persona?: string | undefined + /** Per-child tool scoping. */ + readonly toolFilter?: ToolRestriction | undefined +} + +/** + * Apply one child's scoped composition inside its creation window: a shadowing + * persona section and a tool restriction, both owned by the child's scope and + * therefore invisible to its parent and siblings. + * @param childCtx - the child agent's scoped creation context. + * @param composition - the persona and tool filter to install. + */ +export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { + if (composition.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) + } + if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) +} + +/** Identity and lineage inputs shared by every in-process child creation. */ +export interface ChildCreateInputs { + /** The child's reserved session id. */ + readonly sessionId: SessionId + /** The delegating parent agent. */ + readonly parent: Agent + /** The resolved delegation depth. */ + readonly childDepth: number + /** How many leading seed events came from the parent's log. */ + readonly lineageSeedLength: number +} diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 1f4aa54a0c..80021ce205 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,34 +1,43 @@ /** * Internal continuable-subagent manager: stable child ids, descriptor - * persistence and lookup by known child id, Task-backed activation, and - * steer-or-resume message routing behind `ctx.subagents`. + * persistence, activation admission, the live ownership graph, cold resume, + * and child-first disposal behind `ctx.subagents`. * - * Every continuable activation — initial or resumed, parent- or human-started - * — has exactly one Task and one result. Task settlement awaits the child - * result, disposes the run, and only then records the outcome, so a terminal - * Task leaves the durable child session but no live child Agent. Cancellation - * targets the whole activation: parent and human messages that joined one - * turn share its result and its `killed` outcome. + * A continuable child has one durable Session and at most one process-local + * {@link Activation} — one 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 Agent inbox is the only turn queue, so this 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. * * @module @deepseek-ai/dsh-subagent */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { - SubagentProviderResumeRequest, - SubagentProviderStartRequest, - SubagentResult, - SubagentRun, - SubagentStartRequest, -} from './types.ts' -import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' + Agent, + AgentHandle, + AgentOptions, + CreateAgentOptions, +} from '@deepseek-ai/dsh-agent' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import type { SubagentDescriptorData } from './descriptor.ts' +import { + applyChildComposition, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, +} from './child-agent.ts' +import { seedDescriptorTurn } from './descriptor-seed.ts' +import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' import { SubagentError } from './error.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -44,197 +53,251 @@ 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. */ + | { readonly kind: 'user' } + /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { - /** The `ctx.subagents` provider to establish the child on. */ + /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */ readonly provider: string - /** One-line model-facing Task label (the delegation description). */ - readonly label: string /** - * The delegation request. The service resolves the stable child id and the - * durable descriptor, then supplies the Task-owned cancellation signal and - * `continuation` itself. + * The delegation request. The manager reserves the stable child id, resolves + * the durable descriptor, and composes the child itself. */ - readonly request: Omit + readonly request: Omit + /** Caller cancellation, owning the operation only until inbox acceptance. */ + readonly signal: AbortSignal } -/** Identities returned by a continuable start. */ +/** Identities returned once a continuable child accepted its initial prompt. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId - /** The initial activation's Task id. */ - readonly taskId: TaskId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } -/** - * Options for following up with one continuable child. - */ +/** Options for following up with one continuable child. */ export interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } /** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. + * 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. */ -export type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } - -type StartProvider = (name: string, request: SubagentProviderStartRequest) => Promise -type ResumeProvider = (request: SubagentProviderResumeRequest) => Promise +export type ActivationState = 'running' | 'waiting' | 'settled' /** - * One child's current process-local activation: its Task and, after provider - * publication, its run. Installed before any provider or persistence await - * and removed only after run disposal and Task terminal publication. This - * exists solely so parent and human senders can find the same activation — it - * is not a durable catalog, admission reservation, or run-state machine. + * Lifecycle observer for one Activation's residency epoch, so continuable + * children emit the same start/end pair as one-shot runs. */ -interface ActiveActivation { - /** Assigned in the same synchronous frame as the install, when the Task registers. */ - taskId: TaskId | undefined - /** Filled when the provider publishes; `undefined` while starting or resuming. */ - run: SubagentRun | undefined - /** The activation-owned cancellation authority, created before any await. */ - readonly controller: AbortController - /** The producer's settlement (run disposed, outcome produced); assigned when the Task registers. */ - done: Promise | undefined - /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ - readonly terminal: PromiseWithResolvers +export interface ActivationObserver { + /** Publish the start edge once the epoch is resident. */ + start(): void + /** + * Publish the terminal edge exactly once. An epoch that never became resident + * emits nothing, because it has no start edge to pair. + * @param child - the child agent whose final output the edge reports, if any. + * @param failure - the teardown or durability failure, or `undefined` on success. + */ + settle(child: Agent | undefined, failure: unknown): void +} + +/** Hooks the manager needs from the owning service. */ +export interface ContinuationHost { + /** + * Resolve one provider's continuable-creation contribution, or reject when + * the provider is unknown or lacks the capability. + * @param name - the configured provider name. + * @param request - the reserved identity, delegating parent, and cancellation. + * @returns the provider's detached creation spec. + */ + prepareContinuable(name: string, request: ContinuableCreateRequest): Promise + /** + * 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. + * @returns the observer whose edges this epoch publishes. + */ + observeActivation(provider: string, childId: SessionId, parent: Agent | undefined): ActivationObserver } /** - * Map a child result to the task outcome: completed carries final text, - * aborted is killed, and every other reason is failed without partial output. - * @param result - child terminal result. - * @returns outcome for the `ctx.tasks` registration. + * One residency epoch for a reconstructed continuable child Agent. It directly + * owns the published `AgentHandle`; the manager's private activation-owner + * scope is its structural Cordis owner. */ -function runOutcome(result: SubagentResult): TaskOutcome { - switch (result.stopReason) { - case 'completed': - return { status: 'completed', output: finalText(result.output) } - case 'aborted': - return { status: 'killed' } - case 'error': - case 'max-tokens': - case 'refusal': - return { status: 'failed', detail: result.stopReason } - // Merge-extensible reasons remain failures with their raw detail. - default: - return { status: 'failed', detail: String(result.stopReason) } +interface Activation { + /** The durable child this Activation is an epoch of. */ + readonly childId: SessionId + /** The provider name recorded in the durable descriptor. */ + readonly provider: string + /** The retained live Agent handle, disposed exactly once at settlement. */ + readonly handle: AgentHandle + /** + * Session ids of the child Activations this one owns. Because one Session has + * at most one live Activation, the id identifies the live child without + * another runtime-incarnation reference. Non-empty blocks settlement. + */ + readonly ownedChildren: Set + /** The lifecycle observer that emits this epoch's start and terminal edges. */ + readonly observer: ActivationObserver + /** + * The memoized disposal transaction. Presence IS the admission cutoff: it is + * assigned synchronously when disposal begins, so no delivery can join a + * handle being torn down, and a racing delivery awaits it before cold-resuming + * a new Activation. Every converging releaser shares this one teardown. + */ + disposal: Promise | undefined + /** Renewed whenever a settlement watcher must re-observe quiescence. */ + poke: PromiseWithResolvers +} + +/** + * Read one Activation's current disposal transaction. This indirection exists + * because a mutable field read inside a long-lived closure narrows to its + * last-seen value, which would flatten these genuine runtime checks to + * constants. + * @param activation - the Activation to inspect. + * @returns the in-flight or settled disposal, or `undefined` while resident. + */ +function disposalOf(activation: Activation): Promise | undefined { + return activation.disposal +} + +/** Whether one settlement attempt opened the disposal transaction. */ +type SettlementAttempt = + | { readonly settling: false } + | { readonly settling: true; readonly done: Promise } + +/** Serialize each durable child's delivery, release, and disposal. */ +class ChildLock { + private tails = new Map>() + + /** + * Run `operation` after every previously queued operation for `childId`. + * @param childId - the durable child whose operations are linearized. + * @param operation - the critical section to run in order. + * @returns the operation's own settlement. + */ + run(childId: SessionId, operation: () => Promise): Promise { + const previous = this.tails.get(childId) ?? Promise.resolve() + const result = previous.then(operation, operation) + // Absorb rejections in the chaining tail so one failed critical section + // cannot reject an unrelated later caller. + const tail = result.then(() => undefined, () => undefined) + this.tails.set(childId, tail) + void tail.then(() => { + if (this.tails.get(childId) === tail) this.tails.delete(childId) + }) + return result } } -/** Render infrastructure failure detail without hiding a durability diagnosis. */ -function runFailureDetail(error: unknown): string { - return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' - ? error.message - : String(error) -} - /** - * Await the child result, dispose the run, then return its task outcome. Result - * and disposal failures become `failed`; when both fail, both details survive. - * @param run - live run to settle and release. - * @returns outcome after child resources are released. - */ -export async function settleRun(run: SubagentRun): Promise { - let outcome: TaskOutcome - try { - outcome = runOutcome(await run.result) - } catch (error: unknown) { - outcome = { status: 'failed', detail: runFailureDetail(error) } - } - try { - await run.dispose() - } catch (error: unknown) { - const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` - return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } - } - return outcome -} - -/** Flatten a child's final output blocks to the task's final text. */ -function finalText(blocks: ContentBlock[]): string { - return blocks - .filter((block): block is Extract => block.type === 'text') - .map(block => block.text) - .join('') -} - -/** - * The continuable-subagent orchestration service. Tool schema and UI adapters - * are consumers of this one contract: parent and human messages route through - * {@link followup} and share one activation result and cancellation - * boundary, while foreground one-shot delegation keeps calling - * `ctx.subagents.start()` directly. + * The continuable-subagent orchestration service behind `ctx.subagents`. Tool + * schema and host adapters are consumers of this one contract; foreground + * one-shot delegation keeps calling `ctx.subagents.start()` and never enters + * this lifecycle. */ export class SubagentContinuationManager { - /** Child session id → its current activation. Process-local, never durable. */ - private activations = new Map() + /** Child session id → its live Activation. Process-local, never durable. */ + private activations = new Map() + private readonly locks = new ChildLock() + /** Structural Cordis owner of every Activation handle. */ + private readonly ownerCtx: Context + private draining = false constructor( private readonly ctx: Context, - private readonly startProvider: StartProvider, - private readonly resumeProvider: ResumeProvider, + private readonly host: ContinuationHost, ) { - // Terminal publication is one of the two removal conditions. The exact - // Task id pins the resolution to this activation, never a later same-child one. - ctx.tasks.onTaskDone((snapshot) => { - for (const activation of this.activations.values()) { - if (activation.taskId === snapshot.id) activation.terminal.resolve() - } - }) - // TaskService deliberately keeps producer Tasks alive across a - // follow-up-tool or producer reload, so this manager's disposal must not - // strand the activations it can no longer route to: cancel each one and - // await producer settlement (run disposal) before releasing the map. The - // effect-scoped onTaskDone listener above is already gone by then, so - // terminal publication is resolved here instead of waiting forever. - ctx.effect(() => async () => { - const active = [...this.activations.values()] - this.activations.clear() - for (const activation of active) { - activation.controller.abort('subagent continuation manager disposed') - activation.terminal.resolve() - } - await Promise.allSettled(active.map((activation) => { - /* v8 ignore next 2 -- TaskService invokes `run` synchronously before `start` returns; - * every retained activation has `done`, while registration failure removes it. */ - if (activation.done === undefined) return Promise.resolve() - return activation.done - })) - }, 'subagents.continuations()') + // Ordinary Cordis owner effects unwind in reverse registration order, which + // cannot express the dynamic child graph. Register the private scope's + // structural disposer FIRST and the drain SECOND, so reverse unwind invokes + // the drain before releasing the scope; a cleanup effect on the same scope + // as the Agent handles would let structural handle disposal bypass + // child-first ordering. + const scope = ctx.plugin(function activationOwner() {}) + this.ownerCtx = scope.ctx + ctx.effect(function* (this: SubagentContinuationManager) { + yield scope.dispose + yield () => this.drain() + }.bind(this), 'subagents.continuations()') } /** - * Start a continuable background child: allocate its stable session id, - * snapshot its durable descriptor, and register the initial activation's - * Task. A synchronous validation failure (a non-JSON descriptor input, - * missing persistence, Task preflight) throws without creating a Task; the - * method otherwise returns both identities immediately, without waiting for - * child publication or descriptor durability. Asynchronous startup failure - * settles the returned Task as `failed` (or `killed` when cancelled) after - * any published run is disposed, which can leave an unmaterialized child id - * that later by-id operations report as unavailable. - * @param spec - provider, Task label, and the delegation request. - * @returns the stable child id and the initial activation's Task id. + * Whether this manager still admits new materialization and delivery. Host + * teardown closes admission synchronously through {@link enterDraining}. + * @returns true once draining began. */ - startContinuable(spec: ContinuableStartSpec): ContinuableStart { + get isDraining(): boolean { + return this.draining + } + + /** + * Close admission synchronously: reject new creation, cold resume, and + * delivery so a host can drain the live Activation forest without racing new + * work. Idempotent. + */ + enterDraining(): void { + this.draining = true + } + + /** + * 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 + * through the private activation-owner scope, establish any continuable-parent + * ownership, and submit the initial prompt. Resolves when inbox acceptance + * yields the message id — without waiting for the turn to start or for the + * message to reach the Session log. + * + * Every failure before that acceptance rejects without either id, disposing + * any created handle and rolling back the Activation and parent ownership. + * The caller signal owns lookup, materialization, and admission only until + * acceptance; afterwards the manager owns the Activation independently. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted initial prompt's message id. + */ + async startContinuable(spec: ContinuableStartSpec): Promise { + this.assertAdmitting() this.requirePersistence() - const childId = SessionId(randomUUID()) const request = spec.request - // Snapshot before Task creation: invalid descriptor JSON rejects the call - // with no Task, and the detached value is what reaches the child log. - const agentProvider = request.agentOptions?.provider ?? request.parent.options.provider - const agentModel = request.agentOptions?.model ?? request.parent.options.model + const parent = request.parent + const childId = SessionId(randomUUID()) + const childDepth = resolveChildDepth(parent, request.maxDepth) + // Snapshot before any await: invalid descriptor JSON rejects the call + // before a child exists, and the detached value is what reaches the log. + const agentProvider = request.agentOptions?.provider ?? parent.options.provider + const agentModel = request.agentOptions?.model ?? parent.options.model const descriptor = snapshotSubagentDescriptor({ provider: spec.provider, ...agentProvider !== undefined ? { agentProvider } : {}, @@ -242,293 +305,498 @@ export class SubagentContinuationManager { ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) - const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.startProvider(spec.provider, { - ...request, - signal, - continuation: { sessionId: childId, descriptor }, - })) - return { childId, taskId } + + const prepared = await this.host.prepareContinuable(spec.provider, { + sessionId: childId, + parent, + signal: spec.signal, + }) + spec.signal.throwIfAborted() + this.assertAdmitting() + + const lineageSeedLength = prepared.seed?.length ?? 0 + const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) + const messageId = await this.locks.run(childId, async () => { + const activation = await this.materialize({ + childId, + provider: spec.provider, + parent, + seed, + meta: childSessionMeta(parent, childDepth, lineageSeedLength), + agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), + composition: { persona: request.persona, toolFilter: request.toolFilter }, + signal: spec.signal, + }) + return this.submit(activation, request.prompt, { kind: 'user' }, { kind: 'parent', agent: parent }) + }) + return { childId, messageId } } /** - * Follow up with a known continuable child: steer its running - * activation, or cold-resume the durable session into a fresh Task-backed - * activation. The two routes are reported distinctly so timing-dependent - * routing is observable. Rejection means the message was NOT delivered — in - * particular, losing a race with Task settlement does not fall through to - * cold resume within the same call; a later retry after Task terminal may - * start the next activation. The started Task owns descriptor lookup and - * direct-parent authorization (its AbortSignal exists before that lookup), - * so an unknown, foreign, or descriptor-less child settles the started Task - * as `failed` with a detail reporting the id as unavailable. - * @param parent - the live parent agent sending the message (model tool or - * human adapter); Task access is authorized by its session id. - * @param childId - the stable child session id. + * Deliver one later message to a known continuable child as its next FIFO + * 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. + * + * 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 childId - the durable child session id. * @param content - the user-role content to deliver. - * @param options - caller attribution and cancellation. During live delivery, - * abort cancels the shared activation and rejects only after quiescence. - * @returns whether the content `steered` the existing Task or `started` a new one. + * @param options - durable provenance and caller cancellation. + * @returns the accepted message's inbox id. + * @throws when authority, availability, or admission rejects the delivery. */ async followup( - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, - ): Promise { - this.assertOwnership(childId) - const activation = this.activations.get(childId) - if (activation !== undefined) { - return { - route: 'steered', - taskId: await this.steerActivation( - activation, - parent, - childId, - content, - options.source, - options.signal, - ), - } - } - return { - route: 'started', - taskId: this.resumeActivation(parent, childId, content, options.source), + ): Promise { + this.assertAdmitting() + 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) + // A delivery that arrives after the disposal transaction began must not + // reach a handle being torn down; wait for release, then cold-resume. + if (activation.disposal !== undefined) { + return activation.disposal.then(() => undefined, () => undefined) + } + await this.authorizeLive(authority, activation) + return this.submit(activation, content, options.source, authority) + }) + if (live !== undefined) return live + // The racing disposal completed; retry admission, which now cold-resumes. + this.assertAdmitting() + options.signal.throwIfAborted() } } /** - * Synchronous ownership compare before any by-id routing: a live registry - * Agent outside the association — or different from the associated run's - * agent — was started by something else. Fail instead of adopting an idle - * Agent or attaching an untracked turn. + * Dispose every live Activation forest child-first and await all handles. + * Sibling branches drain independently: one failure is recorded but never + * prevents the remaining handles from being attempted, and the aggregate + * rejects only after every branch settles. + * @returns once every snapshotted Activation released its handle. + * @throws an aggregate error when any branch failed to release. */ - private assertOwnership(childId: SessionId): void { - const live = this.ctx.agents.get(childId) - if (live === undefined) return - const activation = this.activations.get(childId) - if (activation === undefined) { + async drain(): Promise { + this.enterDraining() + // Snapshot roots after closing admission: a root is an Activation no live + // Activation owns, so disposing roots recurses child-first into the forest. + const owned = new Set() + for (const activation of this.activations.values()) { + for (const child of activation.ownedChildren) owned.add(child) + } + const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId)) + const failures = await Promise.all(roots.map(async (activation) => { + try { + await this.dispose(activation) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = failures.filter(failure => failure !== undefined) + if (reasons.length > 0) { + throw new SubagentError( + `continuable subagent teardown failed for ${reasons.length} activation(s): ` + + reasons.map(reason => errorChain(reason)).join('; '), + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + } + + /** Reject new admission once the host or manager began draining. */ + private assertAdmitting(): void { + if (this.draining) { + throw new SubagentError( + 'continuable subagents are draining; the operation was not admitted', + 'DRAINING', + ) + } + } + + /** + * Derive residency from Agent quiescence and the owned-child set. `running` + * covers an active admission, an open turn, or waking inbox work. + */ + private stateOf(activation: Activation): ActivationState { + if (activation.handle.agent.status === 'running') return 'running' + if (activation.ownedChildren.size > 0) return 'waiting' + return 'settled' + } + + /** + * Cold-resume a persisted child: load and authorize its Session, fold the + * generic descriptor, create the Activation through `ctx.agents.resume()`, + * and submit the waiting turn. This never dispatches through a subagent + * provider — the persisted Session already holds the initial prefix and the + * descriptor is the whole reconstruction input. + */ + private async coldResume( + authority: SubagentAuthority, + childId: SessionId, + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + const persistence = this.requirePersistence() + let loaded: Awaited> + try { + loaded = await persistence.load(childId) + } catch (error: unknown) { + throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error }) + } + // The persistence seam takes no signal; recheck before any child work. + 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) + // 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. + const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) + if (descriptor === undefined) { + throw new SubagentError( + `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + + 'do not retry send_message with this id', + 'NOT_RESUMABLE', + ) + } + const activation = await this.materialize({ + childId, + provider: descriptor.provider, + parent: authority.kind === 'parent' ? authority.agent : undefined, + resume: true, + agentOptions: { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + }, + composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, + signal: options.signal, + }) + return this.submit(activation, content, options.source, authority) + } + + /** + * Create or resume the child Agent through the private activation-owner + * scope, install the handle in a fresh Activation, and register ownership on + * a continuation-managed parent. Rejection leaves no Activation, no handle, + * and no ownership membership. + */ + private async materialize(inputs: { + childId: SessionId + provider: string + parent: Agent | undefined + resume?: boolean + seed?: readonly SessionEvent[] + meta?: NonNullable + agentOptions: AgentOptions + composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } + signal: AbortSignal + }): Promise { + const { childId, provider, parent } = inputs + if (this.activations.has(childId)) { + throw new SubagentError( + `subagent "${childId}" already has a live activation; the message was not delivered`, + 'ACTIVATION_CONFLICT', + ) + } + if (this.ctx.agents.get(childId) !== undefined) { throw new SubagentError( `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) } - if (activation.run !== undefined && activation.run.localAgent !== live) { - throw new SubagentError( - `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, - 'OWNERSHIP_CONFLICT', - ) - } - } - - /** Deliver to the running activation's Task through confirmed live steering. */ - private async steerActivation( - activation: ActiveActivation, - parent: Agent, - childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - const taskId = activation.taskId - /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ - if (taskId === undefined) { - throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') - } - // Owner-session authorization plus the live status for admission. - const snapshot = this.ctx.tasks.get(taskId, parent) - if (snapshot.status !== 'running') { - throw new SubagentError( - `subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered ` - + '— retry after it settles to start the next activation', - 'NOT_DELIVERED', - ) - } - const run = activation.run - if (run === undefined) { - throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') - } - if (run.steer === undefined) { - throw new SubagentError( - `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, - 'NOT_DELIVERED', - ) - } - const cancelActivation = (): void => { - activation.controller.abort(signal.reason) - } - signal.addEventListener('abort', cancelActivation, { once: true }) - if (signal.aborted) { - cancelActivation() - signal.removeEventListener('abort', cancelActivation) - return await this.cancelledLiveDelivery(activation, childId) - } + inputs.signal.throwIfAborted() + const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } + const observer = this.host.observeActivation(provider, childId, parent) + let handle: AgentHandle try { - await run.steer(message, source) + handle = inputs.resume === true + ? await this.ownerCtx.agents.resume({ + resumeSessionId: childId, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + : await this.ownerCtx.agents.create({ + sessionId: childId, + ...inputs.meta !== undefined ? { meta: inputs.meta } : {}, + ...inputs.seed !== undefined ? { seed: inputs.seed } : {}, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) } catch (error: unknown) { - try { - signal.throwIfAborted() - } catch { - return await this.cancelledLiveDelivery(activation, childId, error) - } - // Confirmed steering lost the race with request admission. Deliberately no - // cold-resume fallback here: that would attach the message to a turn the - // caller did not observe. - throw new SubagentError( - `subagent "${childId}" stopped before delivery; the message was not delivered`, - 'NOT_DELIVERED', - { cause: error }, - ) - } finally { - signal.removeEventListener('abort', cancelActivation) + // Agent creation provides rollback before handle transfer, so nothing + // outlives this rejection; report the epoch that never became resident. + observer.settle(undefined, error) + throw error } - return taskId + + const activation: Activation = { + childId, + provider, + handle, + ownedChildren: new Set(), + observer, + disposal: undefined, + poke: Promise.withResolvers(), + } + // After transfer, any failure must dispose the created handle, remove the + // Activation, and roll back parent ownership before rejecting. + this.activations.set(childId, activation) + try { + inputs.signal.throwIfAborted() + this.assertAdmitting() + this.acquireOwnership(parent, childId) + } catch (error: unknown) { + // Roll the transfer back completely: the Activation leaves the map, the + // parent's ownership membership is released, and the created handle is + // disposed before this rejection surfaces. + this.activations.delete(childId) + this.releaseOwnership(childId) + activation.disposal = (async () => { + try { + await handle.dispose() + } finally { + observer.settle(handle.agent, error) + } + })() + await activation.disposal.catch(() => undefined) + throw error + } + // Resident: publish the start edge before any turn can run, so observers + // see this epoch before its first request. + observer.start() + this.watchSettlement(activation) + return activation } - /** Reject a cancelled live delivery only after its shared activation is quiescent. */ - private async cancelledLiveDelivery( - activation: ActiveActivation, - childId: SessionId, - cause?: unknown, - ): Promise { - /* v8 ignore if -- a published run implies the producer assigned `done` before its provider await resolved. */ - if (activation.done === undefined) { - throw new Error('published subagent activation has no settlement promise') + /** + * Register the child in a continuation-managed parent's owned set before the + * child can run, so that parent cannot settle while the child is live. A + * 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 + const parentActivation = this.activations.get(parent.id) + if (parentActivation === undefined) return + if (parentActivation.disposal !== undefined) { + throw new SubagentError( + `subagent parent "${parent.id}" is being disposed; the child was not established`, + 'ACTIVATION_CLOSING', + ) } - await activation.done - throw new SubagentError( - `subagent "${childId}" live delivery was cancelled; the message was not delivered`, - 'CANCELLED', - cause === undefined ? undefined : { cause }, + parentActivation.ownedChildren.add(childId) + } + + /** Remove one child from its live owner's set and let that owner re-check settlement. */ + private releaseOwnership(childId: SessionId): void { + for (const candidate of this.activations.values()) { + if (candidate.ownedChildren.delete(childId)) this.wake(candidate) + } + } + + /** Let a settlement watcher re-observe quiescence after ownership or inbox changes. */ + private wake(activation: Activation): void { + activation.poke.resolve() + activation.poke = Promise.withResolvers() + } + + /** + * Submit one message as the child's next FIFO turn and return its accepted + * inbox id. Acceptance is the operation's success boundary; the manager owns + * the Activation independently afterwards. + */ + private submit( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + authority: SubagentAuthority, + ): 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) + const message = createUserMessage({ content, source }) + activation.handle.agent.followup(message) + // Accepted waking work keeps this Activation live until whenIdle() observes + // the complete waking suffix. + this.wake(activation) + return message.id + } + + /** + * 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 { + await Promise.resolve() + this.authorizeLineage( + authority, + activation.childId, + activation.handle.agent.session.header.parentSession, ) } /** - * Cold-resume a persisted child into a fresh Task-backed activation. The - * Task owns its `AbortController` before descriptor lookup: the load, - * direct-parent authorization, and descriptor fold run inside the - * activation, with cancellation rechecked after the un-signalled - * persistence await so an early `task_kill` prevents any later child work. + * 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. */ - private resumeActivation( - parent: Agent, + private authorizeLineage( + authority: SubagentAuthority, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - ): TaskId { - const persistence = this.requirePersistence() - return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { - let loaded: Awaited> - try { - loaded = await persistence.load(childId) - } catch (error: unknown) { - throw new SubagentError( - `subagent "${childId}" is unavailable`, - 'NOT_RESUMABLE', - { cause: error }, - ) - } - // The persistence seam takes no signal; recheck before any child work. - if (signal.aborted) throw new SubagentError('subagent resume was cancelled during lookup', 'CANCELLED') - // Authorize the persisted header before folding: only the direct parent - // recorded at creation may continue this child. - if (loaded.meta.parentSession !== parent.id) { - throw new SubagentError( - `subagent "${childId}" belongs to another parent session`, - 'UNAUTHORIZED', - ) - } - // 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. - const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) - if (descriptor === undefined) { - throw new SubagentError( - `subagent "${childId}" has no supported continuation state and cannot be resumed; ` - + 'do not retry send_message with this id', - 'NOT_RESUMABLE', - ) - } - return this.resumeProvider({ - sessionId: childId, - prompt: message, - source, - parent, - signal, - descriptor, - }) - }) + parentSession: SessionId | undefined, + ): void { + if (authority.kind === 'user') 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`, + 'UNAUTHORIZED', + ) + } + if (parentSession !== parent.id) { + throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED') + } } /** - * Install the activation association, register its Task, and bind the two - * removal conditions. The association is installed before any persistence - * or provider await — the producer body runs synchronously up to its first - * await — and removed only after run disposal (the producer settled) and - * Task terminal publication. This synchronous install admits one activation - * per child in this process; a competing untracked publication still loses - * at the Agent registry collision boundary inside the provider. + * Follow one Activation to settlement: wait for Agent quiescence, then for + * every owned child to complete disposal, and dispose the handle once both + * hold. A `next-turn` delivered while `waiting` wakes the same Agent and + * returns it to `running`, so this re-observes rather than settling early. */ - private startActivation( - childId: SessionId, - label: string, - owner: Agent, - begin: (signal: AbortSignal) => Promise, - ): TaskId { - const activation: ActiveActivation = { - taskId: undefined, - run: undefined, - controller: new AbortController(), - done: undefined, - terminal: Promise.withResolvers(), - } - this.activations.set(childId, activation) - let taskId: TaskId - try { - taskId = this.ctx.tasks.start({ - kind: 'subagent', - label, - owner, - run: (): TaskHooks => { - const done = (async (): Promise => { - try { - const run = await begin(activation.controller.signal) - activation.run = run - return await settleRun(run) - } catch (error: unknown) { - // A pre-publication abort rejects only after the provider's - // creation transaction rolled back to quiescence, so recording - // `killed` here honors the settlement-after-rollback contract. - return activation.controller.signal.aborted - ? { status: 'killed' } - : { status: 'failed', detail: String(error) } - } - })() - activation.done = done - void Promise.allSettled([done, activation.terminal.promise]).then(() => { - /* v8 ignore else -- service teardown clears the map while a producer is still settling. */ - if (this.activations.get(childId) === activation) this.activations.delete(childId) - }) - return { - cancel: (reason?: string) => { - // Cancellation targets the whole activation: every message that - // joined this turn shares the `killed` outcome. - activation.controller.abort(reason ?? 'subagent activation killed') - }, - done, - // No readOutput: the child session owns intermediate detail. + private watchSettlement(activation: Activation): void { + void (async () => { + while (disposalOf(activation) === undefined) { + const poked = activation.poke.promise + await Promise.race([activation.handle.agent.whenIdle(), poked]) + if (disposalOf(activation) !== undefined) return + // Re-check settlement INSIDE the child lock and begin disposal in the + // same critical section, so a concurrent delivery either wins admission + // before the transaction opens or waits for release and cold-resumes. + // Deciding outside the lock would let a delivery observe a not-yet + // resident handle that this watcher is already about to tear down. + const settling = await this.locks.run(activation.childId, () => { + if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') { + return Promise.resolve({ settling: false }) } - }, - }) + // `dispose()` assigns its memoized transaction synchronously, so + // admission is closed before this critical section releases. + return Promise.resolve({ settling: true, done: this.dispose(activation) }) + }) + if (!settling.settling) { + // Still running, or waiting on descendants: re-observe after the next + // accepted message or ownership release. + if (activation.handle.agent.status !== 'running') await poked + continue + } + try { + await settling.done + } catch (error: unknown) { + this.ctx.logger.warn( + `subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`, + ) + } + return + } + })() + } + + /** + * Release one Activation child-first: dispose owned children, checkpoint + * durability, dispose the handle, and release parent ownership. Memoized, so + * host shutdown, manager unload, child release, and normal settlement + * converge on one teardown. + * + * A failed final checkpoint is reported but never prevents handle disposal or + * ownership release, because retaining a failed child would permanently pin + * its ancestors in `waiting`. + */ + private dispose(activation: Activation): Promise { + return (activation.disposal ??= (async () => { + // The memoized assignment above already closed admission for this child: + // no caller may send to a handle after its disposal transaction begins. + this.wake(activation) + const { childId } = activation + let failure: Error | undefined + try { + // Child-first: every owned child must complete disposal before this + // handle is released. + const children = [...activation.ownedChildren] + .map(child => this.activations.get(child)) + .filter((child): child is Activation => child !== undefined) + const childFailures = await Promise.all(children.map(async (child) => { + try { + await this.dispose(child) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = childFailures.filter(reason => reason !== undefined) + if (reasons.length > 0) { + failure = new SubagentError( + `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + const durability = await this.checkpoint(activation) + failure ??= durability + } finally { + this.activations.delete(childId) + try { + await activation.handle.dispose() + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) + } finally { + // Release ownership even on failure: a retained failed child would + // pin its ancestors in `waiting` forever. + this.releaseOwnership(childId) + activation.observer.settle(activation.handle.agent, failure) + } + } + if (failure !== undefined) throw failure + })()) + } + + /** + * Request the final durability checkpoint. Only `true` confirms durability; + * `false` and rejection both report `DURABILITY_FAILED` so the persisted + * child state is known to be possibly missing or stale on a later resume. + */ + private async checkpoint(activation: Activation): Promise { + const child = activation.handle.agent + try { + const participated = await child.ctx.sessions.flush(child.session) + if (participated) return undefined + return new SubagentError( + `subagent "${activation.childId}" required durability checkpoint has no registered listener; ` + + 'the latest child state was not confirmed persisted and may be unavailable or stale on resume', + 'DURABILITY_FAILED', + ) } catch (error: unknown) { - // Task preflight failed; nothing started, so the install rolls back. - this.activations.delete(childId) - throw error + return new SubagentError( + `subagent "${activation.childId}" durability checkpoint failed; the latest child state was not ` + + `confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, + 'DURABILITY_FAILED', + { cause: error }, + ) } - // Same synchronous frame as the install: an observer that can run at all - // runs after this assignment. - activation.taskId = taskId - return taskId } /** Resolve the persistence service continuable children require, or fail loud. */ @@ -544,11 +812,5 @@ export class SubagentContinuationManager { } } -/** Derive a resumed activation's Task label from its message. */ -function resumeLabel(message: ContentBlock[]): string { - const text = finalText(message).trim().replace(/\s+/g, ' ') - if (text.length === 0) return 'subagent follow-up' - return text.length > 80 ? `${text.slice(0, 79)}…` : text -} - +export type { SubagentDescriptorData } export default SubagentContinuationManager diff --git a/packages/subagent/subagent/src/depth.ts b/packages/subagent/subagent/src/depth.ts new file mode 100644 index 0000000000..d9fabab860 --- /dev/null +++ b/packages/subagent/subagent/src/depth.ts @@ -0,0 +1,51 @@ +/** + * Delegation-depth accounting: the recursion budget a parent passes to its + * children. Kept apart from the service so composition helpers can read it + * without importing the registry. + * + * @module @deepseek-ai/dsh-subagent/depth + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' + +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ + subagentDepth?: number + } +} + +/** + * Read an agent's delegation depth, treating absence as top-level depth zero. + * The persisted session header is authoritative and monotone: runtime + * `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it — + * a resumed child arrives with fresh options, and counting it from zero would + * let it delegate as if it were top-level. + * @param agent - the agent whose header and options carry the depth. + * @returns its non-negative safe-integer depth. + * @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer. + */ +export function delegationDepthOf(agent: Agent): number { + const runtime = agent.options.subagentDepth + if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) { + throw new TypeError('agent subagentDepth must be a non-negative safe integer') + } + // The header value was validated at the session boundary (creation and + // persistence load both construct through the store). + return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0) +} + +/** + * Reject a recursion cap that cannot represent an exact delegation depth. + * @param maxDepth - the optional runtime value to validate. + */ +export function assertSubagentMaxDepth(maxDepth: unknown): void { + if (maxDepth !== undefined && ( + typeof maxDepth !== 'number' + || !Number.isSafeInteger(maxDepth) + || maxDepth < 0 + || Object.is(maxDepth, -0) + )) { + throw new TypeError('subagent maxDepth must be a non-negative safe integer') + } +} diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts new file mode 100644 index 0000000000..836b40009d --- /dev/null +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -0,0 +1,31 @@ +/** + * Seeding of a continuable child's durable descriptor event: the model-hidden + * record of the child's declared composition before its first request, so a + * later cold resume can reconstruct it from its own log. + * + * @module @deepseek-ai/dsh-subagent/descriptor-seed + */ + +import { Session } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentDescriptorData } from './descriptor.ts' + +/** + * Build the child's creation seed: any inherited parent-history prefix followed + * by one model-hidden, between-turn `descriptor` event. Staging through a + * `Session` assigns the sequence number and enforces the same lossless-JSON + * rules the durable log does. + * @param childId - the reserved child session id the staged log belongs to. + * @param seed - the inherited completed-turn prefix, or `undefined` for a fresh child. + * @param descriptor - the snapshotted composition record to persist. + * @returns the complete seed events, contiguous from sequence zero. + */ +export function seedDescriptorTurn( + childId: SessionId, + seed: readonly SessionEvent[] | undefined, + descriptor: SubagentDescriptorData, +): SessionEvent[] { + const staged = new Session(childId, seed) + staged.append('subagent/descriptor', descriptor) + return [...staged.events] +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 023ac1fe26..0bfebc8cf5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,11 +13,13 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Public operations express caller intent: `start` returns one ready owned run, - * `startContinuable` starts a Task-backed durable child, and `followup` routes - * later content without exposing whether the child is live. Provider resume - * dispatch stays private because only the continuation manager holds the - * resolved descriptor and authorization facts. + * Public operations express caller intent: `start` returns one ready owned + * one-shot run, `startContinuable` establishes a durable continuable child, and + * `followup` delivers later content without exposing whether the child is + * resident. Continuable children never become a {@link SubagentRun}: the + * continuation manager holds their `AgentHandle` directly and orders every turn + * through the child's own inbox, so providers contribute only the detached + * creation spec and see no handle, turn, or teardown. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -32,36 +34,38 @@ 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 } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' import { SubagentError } from './error.ts' +import { assertSubagentMaxDepth } from './depth.ts' import SubagentContinuationManager from './continuation.ts' import type { + ActivationObserver, + ActivationState, ContinuableStart, ContinuableStartSpec, + SubagentAuthority, SubagentFollowupOptions, - SubagentFollowupResult, } from './continuation.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' export type { + ContinuableCreateRequest, + ContinuableCreateSpec, SubagentCapabilities, - SubagentContinuation, SubagentProvider, - SubagentProviderResumeRequest, - SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, @@ -74,58 +78,28 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { seedDescriptorTurn } from './descriptor-seed.ts' export { SubagentError } from './error.ts' -export { settleRun } from './continuation.ts' +export { settleRun } from './run-settlement.ts' +export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' +export { + applyChildComposition, + childSessionMeta, + resolveChildAgentOptions, + resolveChildDepth, + SubagentDepthError, +} from './child-agent.ts' +export type { ChildComposition } from './child-agent.ts' export type { + ActivationObserver, + ActivationState, ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, + SubagentAuthority, SubagentFollowupOptions, - SubagentFollowupResult, } from './continuation.ts' -declare module '@deepseek-ai/dsh-agent' { - interface AgentOptions { - /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ - subagentDepth?: number - } -} - -/** - * Read an agent's delegation depth, treating absence as top-level depth zero. - * The persisted session header is authoritative and monotone: runtime - * `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it — - * a resumed child arrives with fresh options, and counting it from zero would - * let it delegate as if it were top-level. - * @param agent - the agent whose header and options carry the depth. - * @returns its non-negative safe-integer depth. - * @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer. - */ -export function delegationDepthOf(agent: Agent): number { - const runtime = agent.options.subagentDepth - if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) { - throw new TypeError('agent subagentDepth must be a non-negative safe integer') - } - // The header value was validated at the session boundary (creation and - // persistence load both construct through the store). - return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0) -} - -/** - * Reject a recursion cap that cannot represent an exact delegation depth. - * @param maxDepth - the optional runtime value to validate. - */ -export function assertSubagentMaxDepth(maxDepth: unknown): void { - if (maxDepth !== undefined && ( - typeof maxDepth !== 'number' - || !Number.isSafeInteger(maxDepth) - || maxDepth < 0 - || Object.is(maxDepth, -0) - )) { - throw new TypeError('subagent maxDepth must be a non-negative safe integer') - } -} - declare module 'cordis' { interface Context { subagents: SubagentService @@ -195,19 +169,18 @@ export interface SubagentRunEndInfo { readonly lastAssistantMessage?: ContentBlock[] } -/** Named provider registry with raw and Task-backed continuation operations. */ +/** Named provider registry with one-shot runs and continuable-child operations. */ export class SubagentService extends Service { private providers = new Map() private continuations: SubagentContinuationManager | undefined constructor(ctx: Context) { super(ctx, 'subagents') - ctx.inject(['tasks', 'agents'], (childCtx: Context) => { - const manager = new SubagentContinuationManager( - childCtx, - (name, request) => this.startProvider(name, request), - request => this.resumeProvider(request), - ) + 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.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -217,34 +190,64 @@ export class SubagentService extends Service { } /** - * Start one durable continuable child through a Task-backed initial - * activation. - * @param spec - provider, Task label, and delegation request. - * @returns the stable child id and initial activation Task id. + * Establish one durable continuable child and deliver its initial prompt. + * Resolves 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 back the child entirely. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted prompt's message id. + * @throws when continuation services are unavailable or materialization fails. */ - startContinuable(spec: ContinuableStartSpec): ContinuableStart { + startContinuable(spec: ContinuableStartSpec): Promise { return this.requireContinuations().startContinuable(spec) } /** - * Follow up with a continuable child. A live child is steered and fulfillment - * confirms request admission; an idle child immediately returns a fresh Task - * whose descriptor lookup, authorization, and cold resume may later fail. - * @param parent - live direct parent authorizing the operation. + * 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. * @param childId - durable child session id. * @param content - user-role content to deliver. - * @param options - durable attribution and caller cancellation; aborting a - * live-delivery wait cancels the shared activation and awaits quiescence. - * @returns the existing steered Task or newly started Task. - * @throws when continuation services are unavailable or live delivery is not admitted. + * @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. */ followup( - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, - ): Promise { - return this.requireContinuations().followup(parent, childId, content, options) + ): Promise { + return this.requireContinuations().followup(authority, childId, content, options) + } + + /** + * 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) + } + + /** + * 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. + * @returns once every live Activation released its `AgentHandle`. + * @throws an aggregate error after all branches settle when any failed. + */ + async drainContinuable(): Promise { + const manager = this.continuations + // Absent continuation services means nothing was ever materialized. + if (manager === undefined) return + await manager.drain() } /** @@ -298,40 +301,32 @@ export class SubagentService extends Service { * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ - async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise { - return this.startProvider(name, request) - } - - /** Validate and dispatch one ordinary or service-resolved provider start. */ - private async startProvider( - name: string, - request: SubagentProviderStartRequest, - ): Promise { + async start(name: string, request: SubagentStartRequest): Promise { const provider = this.expectProvider(name) this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) - if (request.continuation !== undefined && provider.resume === undefined) { - throw new SubagentError( - `subagent provider "${provider.name}" does not support continuable children (no resume capability)`, - 'UNSUPPORTED_CAPABILITY', - ) - } - return this.observeRun(name, request.parent, await provider.start(request)) } - /** Dispatch one authorized provider resume and observe its run lifecycle. */ - private async resumeProvider(request: SubagentProviderResumeRequest): Promise { - const name = request.descriptor.provider + /** + * Resolve one provider's detached continuable-creation contribution. Method + * presence on the provider IS the capability, so a provider without it is + * rejected before the manager reserves any child resources. + */ + private async prepareContinuable( + name: string, + request: ContinuableCreateRequest, + ): Promise { const provider = this.expectProvider(name) - if (provider.resume === undefined) { + if (provider.prepareContinuable === undefined) { throw new SubagentError( - `subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`, + `subagent provider "${provider.name}" does not support continuable children ` + + '(no prepareContinuable capability)', 'UNSUPPORTED_CAPABILITY', ) } - return this.observeRun(name, request.parent, await provider.resume(request)) + return provider.prepareContinuable(request) } /** Look up a provider for dispatch or fail loud. */ @@ -354,6 +349,41 @@ export class SubagentService extends Service { return this.continuations } + /** + * 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. + */ + private observeActivation( + provider: string, + childId: SessionId, + parent: Agent | undefined, + ): ActivationObserver { + const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } + let started = false + let settled = false + return { + start: (): void => { + started = true + this.emitLifecycle('subagent/start', identity, parent) + }, + settle: (child: Agent | undefined, failure: unknown): void => { + // A failure before residency has no start edge to pair, and inventing + // one would report a lifecycle the child never had. + if (settled || !started) return + settled = true + const output = failure === undefined ? lastAssistantOutput(child) : undefined + this.emitLifecycle('subagent/end', { + ...identity, + stopReason: failure === undefined ? 'completed' : '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()) @@ -385,14 +415,16 @@ export class SubagentService extends Service { * 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): void - private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void + 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, + 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] @@ -427,6 +459,18 @@ export class SubagentService extends Service { } } +/** + * The child's last assistant message content, for one Activation's terminal + * lifecycle edge. Absent when no assistant message reached the log. + */ +function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined { + if (child === undefined) return undefined + const message = child.session.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 { diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts new file mode 100644 index 0000000000..92d0986bcd --- /dev/null +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -0,0 +1,71 @@ +/** + * Settlement of one ONE-SHOT subagent run into a background-Task outcome. Only + * the one-shot background path uses Tasks; continuable children have no Task, + * no per-message result, and no Task cancellation. + * + * @module @deepseek-ai/dsh-subagent/run-settlement + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type { SubagentResult, SubagentRun } from './types.ts' + +/** Flatten a child's final output blocks to the task's final text. */ +function finalText(blocks: ContentBlock[]): string { + return blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** + * Map a child result to the task outcome: completed carries final text, + * aborted is killed, and every other reason is failed without partial output. + * @param result - child terminal result. + * @returns outcome for the `ctx.tasks` registration. + */ +function runOutcome(result: SubagentResult): TaskOutcome { + switch (result.stopReason) { + case 'completed': + return { status: 'completed', output: finalText(result.output) } + case 'aborted': + return { status: 'killed' } + case 'error': + case 'max-tokens': + case 'refusal': + return { status: 'failed', detail: result.stopReason } + // Merge-extensible reasons remain failures with their raw detail. + default: + return { status: 'failed', detail: String(result.stopReason) } + } +} + +/** Render infrastructure failure detail without hiding a durability diagnosis. */ +function runFailureDetail(error: unknown): string { + return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' + ? error.message + : String(error) +} + +/** + * Await the child result, dispose the run, then return its task outcome. Result + * and disposal failures become `failed`; when both fail, both details survive. + * @param run - live run to settle and release. + * @returns outcome after child resources are released. + */ +export async function settleRun(run: SubagentRun): Promise { + let outcome: TaskOutcome + try { + outcome = runOutcome(await run.result) + } catch (error: unknown) { + outcome = { status: 'failed', detail: runFailureDetail(error) } + } + try { + await run.dispose() + } catch (error: unknown) { + const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` + return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } + } + return outcome +} diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index da75ae67cc..3ff7368b37 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,10 +6,9 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' -import type { SubagentDescriptorData } from './descriptor.ts' /** Identifies one accepted subagent run across its lifecycle event pair. */ export type SubagentRunId = Branded<'SubagentRunId'> @@ -27,11 +26,12 @@ export function SubagentRunId(id: string): SubagentRunId { * 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 * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ export interface SubagentCapabilities { readonly outputSchema: boolean @@ -41,10 +41,10 @@ export interface SubagentCapabilities { } /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ export interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -96,63 +96,37 @@ export interface SubagentStartRequest { } /** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. */ -export interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} - -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -export interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ +export interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData -} - -/** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. - */ -export interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ - readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData +} + +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +export interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] } /** @@ -196,9 +170,12 @@ export interface SubagentResult { } /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ export interface SubagentRun { /** @@ -217,10 +194,8 @@ export interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -228,17 +203,6 @@ export interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } /** @@ -258,23 +222,27 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index af85457262..1fbbc3d3fb 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,9 +1,9 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagents.followup()`. It performs no lifecycle routing of its - * own — steer-or-resume orchestration belongs to the subagent service — and it - * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` - * instances so multiple delegation tools share one control tool. + * `ctx.subagents.followup()`. It performs no lifecycle routing of its own — + * residency and cold resume belong to the subagent service — and it lives apart + * from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple + * delegation tools share one control tool. * @module @deepseek-ai/dsh-tool-subagent-control */ @@ -24,10 +24,10 @@ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'send_message', description: - 'Send a follow-up message to a background subagent by its subagent id. If it is still working, the ' - + 'message joins its current task; if it has finished, this starts a new task that continues the same ' - + 'subagent conversation. Either way the response arrives through the returned task id — collect it ' - + 'with `task_output`. A failure means the message was NOT delivered.', + 'Send a message to a background subagent by its subagent id, continuing the same conversation. It ' + + 'becomes the subagent\'s next turn: if it is still working, the message waits until its current turn ' + + 'finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its ' + + 'transcript by its id to see what it did. A failure means the message was NOT delivered.', parameters: { subagent_id: { type: 'string', @@ -45,30 +45,23 @@ export function apply(ctx: Context): void { type: 'object', additionalProperties: false, properties: { - route: { - type: 'string', - required: true, - enum: ['steered', 'started'], - }, - taskId: { type: 'string', required: true }, + messageId: { type: 'string', required: true }, }, }, - render: (args, value) => [{ + render: (args, _value) => [{ type: 'text', - text: value.route === 'steered' - ? `message delivered to running task ${value.taskId}` - : `message started task ${value.taskId} continuing subagent ${args.subagent_id}`, + text: `message queued as the next turn for subagent ${args.subagent_id}`, }], }, async execute(args, exec) { const parent = exec.agent if (!parent) { - // Non-agent callers have no session to authorize Task access with. + // Parent authority requires an exact live calling agent. throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = await ctx.subagents.followup( - parent, + const messageId = await ctx.subagents.followup( + { kind: 'parent', agent: parent }, SessionId(args.subagent_id), message, { @@ -76,7 +69,7 @@ export function apply(ctx: Context): void { signal: exec.signal, }, ) - return result + return { messageId } }, })) } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 6033bea875..62fef8c055 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -36,9 +36,9 @@ export interface Config { */ enableRunInBackground?: boolean /** - * Background execution policy (default `one-shot`). `continuable` requires - * a provider with persisted resume support and returns both child and Task - * ids; follow-up adapters remain independently optional. + * Background execution policy (default `one-shot`). `continuable` requires a + * provider with the `prepareContinuable` capability and returns the durable + * child id; follow-up adapters remain independently optional. */ backgroundMode?: 'one-shot' | 'continuable' /** @@ -197,7 +197,7 @@ export function apply(ctx: Context, config: Config): void { const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' - if (continuable && provider.resume === undefined) { + if (continuable && provider.prepareContinuable === undefined) { throw new Error( `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, ) @@ -206,9 +206,9 @@ export function apply(ctx: Context, config: Config): void { name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled ? continuable - ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' - + ' stable subagent id and current task id; collect the result with `task_output` and stop it with' - + ' `task_kill`.' + ? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:' + + ' you receive its subagent id and it works on its own. It does not report back to you, so read' + + ' its transcript by that id, or send it more work with `send_message`.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void { run_in_background: { type: 'boolean' as const, description: continuable - ? 'Run as a continuable background subagent and return its subagent and task ids; ' - + 'collect with task_output or stop with task_kill.' + ? 'Run as a background subagent that keeps its conversation and return its subagent id; ' + + 'send it more work with send_message.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, @@ -241,7 +241,14 @@ export function apply(ctx: Context, config: Config): void { properties: { kind: { type: 'string', required: true, const: 'background' }, taskId: { type: 'string', required: true }, - subagentId: { type: 'string' }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'continuable' }, + subagentId: { type: 'string', required: true }, }, }, { @@ -258,10 +265,10 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? value.subagentId === undefined - ? `started background subagent task ${value.taskId}` - : `started subagent ${value.subagentId} as task ${value.taskId}` - : outputValueText(value.output), + ? `started background subagent task ${value.taskId}` + : value.kind === 'continuable' + ? `started subagent ${value.subagentId}` + : outputValueText(value.output), }], }, async execute(args, exec) { @@ -288,16 +295,14 @@ export function apply(ctx: Context, config: Config): void { throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') } if (continuable) { - const started = ctx.subagents.startContinuable({ + // Resolves at inbox acceptance: the child owns its own turns from + // there, so this call neither waits for nor collects a result. + const started = await ctx.subagents.startContinuable({ provider: config.provider, - label: args.description, request, + signal: exec.signal, }) - return { - kind: 'background' as const, - taskId: started.taskId, - subagentId: started.childId, - } + return { kind: 'continuable' as const, subagentId: started.childId } } const tasks = ctx.get('tasks') if (tasks === undefined) { From 1c30548068ad21384c65c758f05f51efef6cb9da Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:31:02 +0800 Subject: [PATCH 02/66] test(subagent): rewrite continuation spec for activation lifecycle Covers the inbox-acceptance return boundary, pre-acceptance rollback, caller-signal ownership on both sides of acceptance, residency-only routing, single-inbox FIFO ordering across parent and user origins, waiting wakeup with a retained handle, cold resume without the historical parent, ownership registration and release, child-first disposal, send-versus-dispose races, durability failure without an ownership leak, per-epoch lifecycle pairing, and the absence of cancellation, steering, and report surfaces. --- .../subagent/tests/continuation.spec.ts | 1261 +++++++---------- 1 file changed, 474 insertions(+), 787 deletions(-) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 24392d847e..8335d665e1 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -11,17 +11,14 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' -import { TaskId } from '@deepseek-ai/dsh-tasks' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { - settleRun, SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' +import type { SubagentAuthority, SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -56,13 +53,14 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */ +/** Boot the full continuable stack: loop, persistence, providers, and subagents. */ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) let disposePersistence: (() => Promise) | undefined + let root: string | undefined if (options.persistence !== false) { - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) + root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) disposePersistence = () => persistenceFiber.dispose() @@ -71,83 +69,88 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent, disposePersistence } + return { ctx, parent, disposePersistence, root } } async function setup(script: Script, options: { persistence?: boolean } = {}) { const adapter = new MockAdapter(script) - const { ctx, parent } = await setupWith(adapter, options) - return { ctx, parent, adapter } + const booted = await setupWith(adapter, options) + return { ...booted, adapter } } -function startSpec(parent: Agent, provider = 'spawn') { +const testSignal = new AbortController().signal + +function startSpec(parent: Agent, provider = 'spawn', signal: AbortSignal = testSignal) { return { provider, - label: 'delegated work', request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + signal, } } -async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { - return ctx.tasks.wait(taskId, 5_000, parent) -} - -async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { - const continuations = ctx.subagents as unknown as { - continuations: { activations: Map } - } - await new Promise((resolve) => { - const timer = setInterval(() => { - if (continuations.continuations.activations.get(childId)?.run !== undefined) { - clearInterval(timer) - resolve() - } - }, 5) - }) -} - function message(text: string) { return [{ type: 'text' as const, text }] } -const coordinatorSource = { - kind: 'coordinator', - senderSessionId: SessionId('parent'), -} as const -const testSendSignal = new AbortController().signal +function hasUserText(events: readonly SessionEvent[], text: string): boolean { + return events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text === text)) +} + +/** Every user-role message text in log order, for FIFO assertions. */ +function userTexts(events: readonly SessionEvent[]): string[] { + return events.flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) +} function followup( ctx: Context, - parent: Agent, + authority: SubagentAuthority, childId: SessionId, content: ReturnType, - signal: AbortSignal = testSendSignal, + signal: AbortSignal = testSignal, ) { - return ctx.subagents.followup(parent, childId, content, { + return ctx.subagents.followup(authority, childId, content, { source: { kind: 'user' }, signal, }) } -describe('SubagentService.startContinuable', () => { - it('returns both identities immediately; the Task settles with the child result after disposal', async () => { - const { ctx, parent } = await setup([textResponse('first answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - expect(started.childId).toMatch(/[0-9a-f-]{36}/) - expect(started.taskId).toBe('subagent-1') +/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.subagents.activationState(childId)).toBeUndefined() + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(started.taskId, parent).text).toBe('first answer') - // Disposal ordering: the terminal Task leaves no live child Agent. - expect(ctx.agents.get(started.childId)).toBeUndefined() +describe('SubagentService.startContinuable', () => { + it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first answer')]) + const enqueued: { id: MessageId; loggedYet: boolean }[] = [] + ctx.on('agent/inbox/enqueue', (agent, accepted) => { + // Acceptance is the boundary `startContinuable` resolves at, so observe + // the log state exactly there rather than after later microtasks. + enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + + expect(started.childId).toMatch(/[0-9a-f-]{36}/) + // The returned id is exactly the accepted inbox message's id, and nothing + // was logged or requested to earn it. + expect(enqueued).toEqual([{ id: started.messageId, loggedYet: false }]) + expect(adapter.requests).toEqual([]) + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'child task')).toBe(true) }) - it('fails a continuable Task before dispatch when its provider has no resume capability', async () => { + it('rejects without ids when the provider has no prepareContinuable capability', async () => { const { ctx, parent } = await setup([]) const start = vi.fn(async () => { throw new Error('must not dispatch') }) ctx.subagents.registerProvider({ @@ -157,48 +160,30 @@ describe('SubagentService.startContinuable', () => { start, }) - const started = ctx.subagents.startContinuable(startSpec(parent, 'one-shot')) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('does not support continuable children') + await expect(ctx.subagents.startContinuable(startSpec(parent, 'one-shot'))) + .rejects.toThrow(/does not support continuable children/) expect(start).not.toHaveBeenCalled() + // No child Agent and no session were created. + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) - it('fails the Task when persistence detaches before the activation completes', async () => { - const releaseResponse = Promise.withResolvers() - const adapter = new GatedAdapter([ - { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, - ]) - const { ctx, parent, disposePersistence } = await setupWith(adapter) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - - await disposePersistence!() - releaseResponse.resolve(undefined) - - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('durability checkpoint failed') - expect(snapshot.detail).toContain('required durability checkpoint has no registered listener') - expect(ctx.agents.get(started.childId)).toBeUndefined() + it('rejects synchronously when persistence is not configured', async () => { + const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toThrow(/require session persistence/) }) - it('publishes the service-allocated child id and appends the pre-turn descriptor', async () => { + it('publishes the reserved child id and appends the pre-turn descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) - const seen: SessionEvent[] = [] - ctx.on('session/event', (session, event) => { - if (session.id !== SessionId('parent')) seen.push(event) - }) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) - const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') - const turnStartIndex = seen.findIndex(event => event.type === 'turn/start') - const firstAssistant = seen.findIndex(event => event.type === 'assistant/message') + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') + const turnStartIndex = loaded.events.findIndex(event => event.type === 'turn/start') + expect(descriptorIndex).toBeGreaterThanOrEqual(0) expect(descriptorIndex).toBeLessThan(turnStartIndex) - expect(descriptorIndex).toBeLessThan(firstAssistant) - const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> + const descriptor = loaded.events[descriptorIndex] as SessionEvent<'subagent/descriptor'> expect(descriptor.data).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', @@ -207,732 +192,434 @@ describe('SubagentService.startContinuable', () => { }) // Model-hidden: the descriptor never carries surface metadata. expect('surfaceOp' in descriptor).toBe(false) - - // The durable log kept the exact service-allocated id. - const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.meta.id).toBe(started.childId) expect(loaded.meta.parentSession).toBe(SessionId('parent')) - expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) }) - it.each(['block', 'throw'] as const)( - 'persists the descriptor before initial prompt admission can $0', - async (outcome) => { - const { ctx, parent, adapter } = await setup([]) - ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { - if (subject === parent) return next() - if (outcome === 'block') return { kind: 'block', reason: 'blocked by policy' } - throw new Error('prompt admission failed') - }) - - const started = ctx.subagents.startContinuable(startSpec(parent)) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(adapter.requests).toEqual([]) - const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptorIndexes = loaded.events.flatMap((event, index) => - event.type === 'subagent/descriptor' ? [index] : []) - expect(descriptorIndexes).toHaveLength(1) - expect(loaded.events.some(event => event.type === 'turn/start')).toBe(false) - }, - ) - - it('rejects synchronously with no Task when persistence is not configured', async () => { - const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) - expect(() => ctx.subagents.startContinuable(startSpec(parent))) - .toThrow(/require session persistence/) - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('rolls back the activation when Task preflight throws', async () => { + it('rolls the child back completely when the caller signal aborts before acceptance', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - const realStart = ctx.tasks.start.bind(ctx.tasks) - ctx.tasks.start = () => { throw new Error('task preflight failed') } - try { - expect(() => ctx.subagents.startContinuable(startSpec(parent))) - .toThrow('task preflight failed') - } finally { - ctx.tasks.start = realStart - } - const continuations = ctx.subagents as unknown as { - continuations: { activations: Map } - } - expect(continuations.continuations.activations.size).toBe(0) - }) - - it('rejects a non-JSON descriptor input synchronously with no Task', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - const spec = startSpec(parent) - expect(() => ctx.subagents.startContinuable({ - ...spec, - // A symbol survives the static ToolRestriction type only through this - // cast — exactly the durable-boundary input the snapshot rejects. - request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } }, - })).toThrow(/not losslessly JSON-serializable/) - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('settles the Task as failed when provider startup fails after the ids were returned', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - const spec = { - provider: 'spawn', - label: 'broken delegation', - request: { - prompt: [{ type: 'text' as const, text: 'child task' }], - parent, - // The spawn provider enforces depth: parent depth 0 → child depth 1 > 0. - maxDepth: 0, - }, - } - const started = ctx.subagents.startContinuable(spec) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('maxDepth') - // The unmaterialized child id is reported unavailable on later use. - const followUp = await followup(ctx, parent, started.childId, message('hello?')) - expect(followUp.route).toBe('started') - const failed = await waitTerminal(ctx, followUp.taskId, parent) - expect(failed.status).toBe('failed') - expect(failed.detail).toContain('unavailable') - }) - - it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { - const { ctx, parent } = await setup(['hang']) - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Let the child publish and begin its turn. - await new Promise(resolve => setTimeout(resolve, 30)) - expect(ctx.agents.get(started.childId)).toBeDefined() - expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - expect(ctx.agents.get(started.childId)).toBeUndefined() - }) - - it('task_kill during the final durability checkpoint settles killed', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const checkpointStarted = Promise.withResolvers() - const releaseCheckpoint = Promise.withResolvers() - let flushes = 0 - ctx.on('session/flush', async (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes !== 2) return - checkpointStarted.resolve(undefined) - await releaseCheckpoint.promise + const controller = new AbortController() + // Abort inside the child's creation window: setup runs before publication. + ctx.on('agent/created', (child) => { + if (child !== parent) controller.abort('caller gave up') }) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await checkpointStarted.promise - expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') - releaseCheckpoint.resolve(undefined) + await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal))) + .rejects.toThrow() + // No Activation, no live child Agent, and no parent ownership remains. + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + }) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - expect(ctx.agents.get(started.childId)).toBeUndefined() + it('rejects a continuable child that would exceed the configured depth cap', async () => { + const { ctx, parent } = await setup([]) + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { prompt: message('deep'), parent, maxDepth: 0 }, + })).rejects.toThrow(/exceeds maxDepth 0/) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + + it('records the declared persona in the descriptor and reapplies it on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('scoped'), textResponse('resumed')]) + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('scoped work'), + parent, + persona: 'You are scoped.', + }, + }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptor = loaded.events.find(event => event.type === 'subagent/descriptor') + expect(descriptor?.data).toMatchObject({ persona: 'You are scoped.' }) + + // Cold resume reconstructs the declared composition from that descriptor. + await followup(ctx, { kind: 'user' }, 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) }) }) -describe('SubagentService.followup', () => { - it('fails a cold-resume Task when the provider loses its resume capability', async () => { - const { ctx, parent } = await setup([textResponse('first answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - const provider = ctx.subagents.getProvider('spawn')! - Object.defineProperty(provider, 'resume', { value: undefined, configurable: true }) - - const next = await followup(ctx, parent, started.childId, message('continue')) - const snapshot = await waitTerminal(ctx, next.taskId, parent) - - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('does not support resuming persisted children') - }) - - it('omits undeclared model selectors and rejects a provider without live delivery', async () => { - const { ctx } = await setup([]) - const result = Promise.withResolvers<{ - output: { type: 'text'; text: string }[] - stopReason: 'completed' - }>() - let descriptor: SessionEvent<'subagent/descriptor'>['data'] | undefined - ctx.subagents.registerProvider({ - name: 'no-steer', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async (request) => { - descriptor = request.continuation?.descriptor - return { - id: request.continuation!.sessionId, - localAgent: undefined, - result: result.promise, - async dispose() {}, - } - }, - resume: async () => { throw new Error('not used') }, - }) - const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) - const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer')) - await waitPublishedRun(ctx, started.childId) - - expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - await expect(followup(ctx, parent, started.childId, message('join'))) - .rejects.toThrow(/provider does not accept live delivery/) - - let terminalDeliveryError: unknown - let terminalDelivery: Promise | undefined - ctx.tasks.onTaskDone((snapshot) => { - if (snapshot.id !== started.taskId) return - terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then( - () => undefined, - (error: unknown) => { - terminalDeliveryError = error - }, - ) - }) - result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) - await waitTerminal(ctx, started.taskId, parent) - await terminalDelivery - expect(String(terminalDeliveryError)).toContain('is completed') - }) - - it('rejects a registry agent different from the associated run agent', async () => { - const { ctx, parent } = await setup([]) - const result = Promise.withResolvers<{ - output: { type: 'text'; text: string }[] - stopReason: 'completed' - }>() - ctx.subagents.registerProvider({ - name: 'mismatched-local', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async (request) => { - const childId = request.continuation!.sessionId - const handle = await ctx.agents.create({ - sessionId: childId, - meta: { parentSession: request.parent.id }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - return { - id: childId, - localAgent: {} as Agent, - result: result.promise, - dispose: () => handle.dispose(), - } - }, - resume: async () => { throw new Error('not used') }, - }) - const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) - await waitPublishedRun(ctx, started.childId) - - await expect(followup(ctx, parent, started.childId, message('join'))) - .rejects.toThrow(/registry agent is not the associated activation's agent/) - result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) - await waitTerminal(ctx, started.taskId, parent) - }) - - it('steers a running activation into the existing Task without creating a second Task', async () => { - // Hold the child's first model call open so the child is observably - // running when the message arrives; the steered content then drives a - // second step in the SAME turn. - let releaseFirst!: () => void - const gate = new Promise((resolve) => { releaseFirst = resolve }) +describe('SubagentService.followup residency routing', () => { + it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => { + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ - { chunks: textResponse('first step answer'), gate }, - { chunks: textResponse('steered turn answer') }, + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + { chunks: textResponse('third') }, ]) const { ctx, parent } = await setupWith(adapter) - - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Wait until the first immutable request has crossed the adapter boundary. - await new Promise((resolve) => { - const timer = setInterval(() => { - if (adapter.requests.length === 1) { - clearInterval(timer) - resolve() - } - }, 5) - }) - - const delivery = ctx.subagents.followup( - parent, - started.childId, - message('also consider Y'), - { source: coordinatorSource, signal: testSendSignal }, - ) - releaseFirst() - const delivered = await delivery - expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - // Exactly one Task exists: steering created none. - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) - // The steered content joined the SAME child turn and drove another step. - const output = ctx.tasks.read(started.taskId, parent) - expect(output.text).toBe('steered turn answer') - const loaded = await ctx.sessionPersistence.load(started.childId) - const steering = loaded.events.find( - (event): event is SessionEvent<'steering/message'> => event.type === 'steering/message', - ) - expect(steering?.data.message.source).toEqual(coordinatorSource) - }) - - it('cancels the active Task without enqueueing when live delivery is already aborted', async () => { - const { ctx, parent, adapter } = await setup(['hang']) - const started = ctx.subagents.startContinuable(startSpec(parent)) + 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') + + // 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, { kind: 'user' }, started.childId, message('from user')) + expect(parentMessage).not.toBe(userMessage) + // Still the same Activation: no second child Agent was created. + expect(ctx.agents.get(started.childId)).toBe(child) + + releaseFirst.resolve() + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'from parent', 'from user']) + }) + + it('cold-resumes a settled child into a new Activation', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const messageId = await followup(ctx, { kind: 'user' }, started.childId, message('continue please')) + expect(messageId).toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'continue please']) + // One descriptor only: cold resume never re-seeds it. + expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1) + }) + + it('wakes a waiting Activation instead of cold-resuming it', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + // The child delegates, then finishes its own turn while the grandchild runs. + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('woken') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // The child starts its own continuable grandchild, then goes quiescent. + 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') + }, { timeout: 5_000 }) + // Waiting retains the handle: the same Agent is still live. + expect(ctx.agents.get(started.childId)).toBe(child) + + await followup(ctx, { kind: 'user' }, started.childId, message('while waiting')) + // Woken back to running on the SAME Activation. + expect(ctx.agents.get(started.childId)).toBe(child) + + releaseGrandchild.resolve() + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting']) + }) + + 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'))) + .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, { kind: 'user' }, 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. + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + await run.result + await ctx.sessions.flush(run.localAgent!.session) + const oneShotId = run.id + await run.dispose() + + await expect(followup(ctx, { kind: 'user' }, 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, { kind: 'user' }, SessionId('missing'), message('hello'))) + .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) + }) + + it('cold-resumes after losing a race with final disposal', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // Send exactly while the Activation is settling: one side wins the cutoff, + // and a delivery that loses waits for release and cold-resumes. + await child.whenIdle() + const delivery = followup(ctx, { kind: 'user' }, started.childId, message('raced')) + + await expect(delivery).resolves.toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'raced')).toBe(true) + }) +}) + +describe('continuable child ownership', () => { + it('keeps a parent Activation waiting until its child completes disposal', async () => { + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + + await vi.waitFor(() => { + expect(ctx.subagents.activationState(started.childId)).toBe('waiting') + }, { timeout: 5_000 }) + // Child-first: the parent handle is retained while the grandchild is live. + expect(ctx.agents.get(started.childId)).toBe(child) + expect(ctx.agents.get(grandchild.childId)).toBeDefined() + + releaseGrandchild.resolve() + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + + it('does not add a top-level parent to the waiting graph', async () => { + 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() + expect(ctx.agents.get(parent.id)).toBe(parent) + }) +}) + +describe('continuable durability and teardown', () => { + it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => { + const releaseResponse = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, + ]) + const { ctx, parent, disposePersistence } = await setupWith(adapter) + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // Remove every durability listener, so the final checkpoint cannot confirm. + await disposePersistence!() + releaseResponse.resolve() + + // The handle is still disposed and ownership released, so nothing is pinned. + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('durability'))).toBe(true) + }) + }) + + it('disposes every live Activation forest child-first on manager teardown', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) + + const disposals: SessionId[] = [] + ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + const drained = ctx.subagents.drainContinuable() + // Let the held model call observe its cancellation so quiescence can settle. + hold.resolve() + await drained + + // Child-first: the grandchild's disposal precedes its parent's. + expect(disposals.indexOf(grandchild.childId)).toBeGreaterThanOrEqual(0) + expect(disposals.indexOf(grandchild.childId)) + .toBeLessThan(disposals.indexOf(started.childId)) + // Durable sessions survive process-local teardown. + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + }) + + it('rejects new materialization and delivery once draining begins', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await ctx.subagents.drainContinuable() + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await expect(followup(ctx, { kind: 'user' }, started.childId, message('too late'))) + .rejects.toMatchObject({ code: 'DRAINING' }) + }) + + it('has no automatic replay for an accepted but unlogged message', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // Accepted into the inbox, but this queued turn never opens. + await followup(ctx, { kind: 'user' }, started.childId, message('never logged')) + + const drained = ctx.subagents.drainContinuable() + hold.resolve() + await drained + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + // Only what actually reached the log is reconstructable. + expect(hasUserText(loaded.events, 'never logged')).toBe(false) + }) +}) + +describe('continuable lifecycle observation', () => { + it('emits one paired start/end per residency epoch', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const starts: SubagentRunInfo[] = [] + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/start', info => { starts.push(info) }) + ctx.on('subagent/end', info => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + + // A cold resume is a NEW epoch with its own pair. + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(2) }) + + expect(starts).toHaveLength(2) + expect(starts.map(info => info.id)).toEqual([started.childId, started.childId]) + expect(starts.map(info => info.provider)).toEqual(['spawn', 'spawn']) + // Each end pairs its own start's runId. + expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) + }) +}) + +describe('continuable public surface', () => { + it('exposes no cancellation, steering, or report operation', async () => { + const { ctx } = await setup([]) + const subagents: Record = ctx.subagents as unknown as Record + for (const absent of ['cancel', 'kill', 'steer', 'steerContinuable', 'report', 'resume']) { + expect(subagents[absent]).toBeUndefined() + } + // No steering tool and no report tool are registered by this seam. + const names = ctx.tools.schemas().map(schema => schema.name) + expect(names).not.toContain('report') + expect(names).not.toContain('steer_subagent') + }) + + it('keeps one-shot runs free of a steering capability', async () => { + const { ctx, parent } = await setup([textResponse('one shot')]) + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + expect('steer' in run).toBe(false) + await run.result + await run.dispose() + }) + + it('reports a caller-signal abort before acceptance without delivering', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + const controller = new AbortController() - controller.abort('caller already cancelled') + controller.abort('caller gave up') + await expect(followup(ctx, { kind: 'user' }, started.childId, message('aborted'), controller.signal)) + .rejects.toThrow() - await expect(followup( - ctx, - parent, - started.childId, - message('must not enqueue'), - controller.signal, - )).rejects.toMatchObject({ code: 'CANCELLED' }) - expect(ctx.agents.get(started.childId)).toBeUndefined() - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + expect(hasUserText(loaded.events, 'aborted')).toBe(false) }) - it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', 'structured_output', { answer: 7 }), + it('does not cancel an accepted turn when the caller signal aborts afterwards', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, ]) - const startedTool = Promise.withResolvers() - const releaseTool = Promise.withResolvers() - ctx.on('tools/pre-execute', async (exec, next) => { - if (exec.name === 'structured_output') { - startedTool.resolve(undefined) - await releaseTool.promise - } - return next() - }) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const base = startSpec(parent) - const started = ctx.subagents.startContinuable({ - ...base, - request: { - ...base.request, - outputSchema: { - type: 'object', - properties: { answer: { type: 'number' } }, - required: ['answer'], - }, - }, - }) - await startedTool.promise + const controller = new AbortController() + await followup(ctx, { kind: 'user' }, started.childId, message('survives'), controller.signal) + // After acceptance the manager owns the Activation independently. + controller.abort('caller gave up') - const delivery = ctx.subagents.followup( - parent, - started.childId, - message('follow-up that terminal policy rejects'), - { source: coordinatorSource, signal: testSendSignal }, - ) - releaseTool.resolve(undefined) - await expect(delivery).rejects.toThrow(/message was not delivered/) - - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(adapter.requests).toHaveLength(1) + releaseFirst.resolve() + await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + expect(hasUserText(loaded.events, 'survives')).toBe(true) }) +}) - it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { - const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - expect(ctx.agents.get(started.childId)).toBeUndefined() - - const followUp = await ctx.subagents.followup( - parent, - started.childId, - message('and then?'), - { source: coordinatorSource, signal: testSendSignal }, - ) - expect(followUp.route).toBe('started') - expect(followUp.taskId).not.toBe(started.taskId) - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer') - // Fresh activation disposed again: durable child, no live Agent. - expect(ctx.agents.get(started.childId)).toBeUndefined() - - // The durable transcript accumulated BOTH activations' turns. - const loaded = await ctx.sessionPersistence.load(started.childId) - const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') - expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) - .toEqual(['child task', 'and then?']) - expect(userMessages.map(event => event.data.source)) - .toEqual([{ kind: 'user' }, coordinatorSource]) - }) - - it('reconstructs the declared composition on cold resume', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const spec = { - provider: 'spawn', - label: 'scoped delegation', - request: { - prompt: [{ type: 'text' as const, text: 'child task' }], - parent, - persona: 'You are the resumable child.', - toolFilter: { deny: [] as string[] }, - }, - } - const started = ctx.subagents.startContinuable(spec) - await waitTerminal(ctx, started.taskId, parent) - - const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor') - expect(descriptor?.data.persona).toBe('You are the resumable child.') - expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - - const followUp = await followup(ctx, parent, started.childId, message('continue')) - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('completed') - // The resumed child's system prompt carried the persona back. - const resumed = await ctx.sessionPersistence.load(started.childId) - const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header') - expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.') - }) - - it('fork children resume from their own transcript without re-forking parent history', async () => { - const { ctx, parent } = await setup([ - textResponse('parent turn one'), - textResponse('fork first answer'), - textResponse('parent turn two'), - textResponse('fork second answer'), - ]) - parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) - await parent.whenIdle() - - const started = ctx.subagents.startContinuable(startSpec(parent, 'fork')) - await waitTerminal(ctx, started.taskId, parent) - const firstLoad = await ctx.sessionPersistence.load(started.childId) - const seedLength = firstLoad.meta.seedLength ?? 0 - expect(seedLength).toBeGreaterThan(0) - - // The parent gains NEW history the resume must not re-fork. - parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) - await parent.whenIdle() - - const followUp = await followup(ctx, parent, started.childId, message('follow up')) - await waitTerminal(ctx, followUp.taskId, parent) - const resumed = await ctx.sessionPersistence.load(started.childId) - // The persisted seed boundary is unchanged and parent turn two is absent. - expect(resumed.meta.seedLength).toBe(seedLength) - const texts = resumed.events - .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') - .map(event => (event.data.content[0] as { text: string }).text) - expect(texts).toContain('parent question one') - expect(texts).not.toContain('parent question two') - }) - - it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - const followUp = await followup(ctx, parent, started.childId, message('go on')) - - const childAgents: Agent[] = [] - const stop = ctx.on('agent/created', (agent: Agent) => { - if (agent.id === started.childId) childAgents.push(agent) - }) - await waitTerminal(ctx, followUp.taskId, parent) - stop() - // The resumed runtime options carry no depth, so the header keeps the floor. - const resumedChild = childAgents.at(-1) - expect(resumedChild).toBeDefined() - expect(resumedChild!.session.header.delegationDepth).toBe(1) - }) - - it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => { - const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')]) - const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) - const started = ctx.subagents.startContinuable(startSpec(otherParent)) - await waitTerminal(ctx, started.taskId, otherParent) - - const attempt = await followup(ctx, parent, started.childId, message('mine now')) - expect(attempt.route).toBe('started') - const snapshot = await waitTerminal(ctx, attempt.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('another parent session') - }) - - it('rejects a persisted child with no descriptor as not resumable', async () => { - const { ctx, parent } = await setup([textResponse('plain child')]) - // A plain (non-continuable) child session persisted under this parent. - const handle = await ctx.agents.create({ - sessionId: SessionId('plain-child'), - meta: { parentSession: parent.id, delegationDepth: 1 }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } })) - await handle.agent.whenIdle() - await handle.dispose() - - const attempt = await followup(ctx, parent, SessionId('plain-child'), message('continue?')) - const snapshot = await waitTerminal(ctx, attempt.taskId, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain( - 'has no supported continuation state and cannot be resumed; do not retry send_message with this id', - ) - }) - - it('derives fallback and bounded labels for resumed activations', async () => { - const { ctx, parent } = await setup([]) - const blank = await followup(ctx, parent, SessionId('blank-child'), message(' ')) - const longText = 'x'.repeat(100) - const long = await followup(ctx, parent, SessionId('long-child'), message(longText)) - - expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') - expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) - await Promise.all([ - waitTerminal(ctx, blank.taskId, parent), - waitTerminal(ctx, long.taskId, parent), - ]) - }) - - it('rejects delivery to a live agent outside continuation ownership', async () => { +describe('continuable errors', () => { + it('rejects a second live Activation for the same durable child', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - // A live child created outside continuation orchestration. - const handle = await ctx.agents.create({ - sessionId: SessionId('rogue-child'), - meta: { parentSession: parent.id }, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) + // Occupy the id with an unmanaged live Agent. + const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' }) + await ctx.sessions.flush(squatter.session) + await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello'))) .rejects.toThrow(SubagentError) - await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(/outside continuation ownership.*not delivered/) - await handle.dispose() - }) - - it('does not fall through to cold resume when steering loses the admission race', async () => { - // Deterministic race: hold run disposal open so the association still - // names a run whose child turn has already ended. - const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) - let releaseDispose!: () => void - const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) - const provider = ctx.subagents.getProvider('spawn')! - const realStart = provider.start.bind(provider) - provider.start = async (request) => { - const run = await realStart(request) - const realDispose = run.dispose.bind(run) - return { - ...run, - ...run.steer !== undefined ? { steer: run.steer.bind(run) } : {}, - dispose: async () => { - await disposeGate - return realDispose() - }, - } - } - - const started = ctx.subagents.startContinuable(startSpec(parent)) - // Wait for the child to finish its turn while the run remains undisposed - // and the association therefore still holds. - await new Promise((resolve) => { - const timer = setInterval(() => { - const child = ctx.agents.get(started.childId) - if (child !== undefined && child.status === 'idle' - && child.session.events.some(event => event.type === 'turn/end')) { - clearInterval(timer) - resolve() - } - }, 5) - }) - - // Confirmed steering finds the settled child, fails loud, and does NOT start - // a cold resume within this call. - await expect(followup(ctx, parent, started.childId, message('too late?'))) - .rejects.toThrow(/not delivered/) - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) - releaseDispose() - await waitTerminal(ctx, started.taskId, parent) - // AFTER the Task settles, retry legitimately starts the next activation. - const retry = await followup(ctx, parent, started.childId, message('retry')) - expect(retry.route).toBe('started') - await waitTerminal(ctx, retry.taskId, parent) - }) - - it('each follow-up Task result is fenced to the parent session', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - const followUp = await followup(ctx, parent, started.childId, message('more')) - const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) - expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) - }) - - it('kills a cold-resume activation during descriptor lookup without starting child work', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - // Make the persistence load hang until the kill lands. - const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - let releaseLoad!: () => void - const gate = new Promise((resolve) => { releaseLoad = resolve }) - ctx.sessionPersistence.load = async (id) => { - await gate - return realLoad(id) - } - - const followUp = await followup(ctx, parent, started.childId, message('follow up')) - expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') - releaseLoad() - const snapshot = await waitTerminal(ctx, followUp.taskId, parent) - expect(snapshot.status).toBe('killed') - // Cancellation during lookup prevented any child publication. - expect(ctx.agents.get(started.childId)).toBeUndefined() - }) - - it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')]) - const started = ctx.subagents.startContinuable(startSpec(parent)) - await waitTerminal(ctx, started.taskId, parent) - - const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) - let releaseLoad!: () => void - const gate = new Promise((resolve) => { releaseLoad = resolve }) - ctx.sessionPersistence.load = async (id) => { - await gate - return realLoad(id) - } - - const first = await followup(ctx, parent, started.childId, message('first follow-up')) - expect(first.route).toBe('started') - // The association is installed synchronously, so the competing caller - // observes the pending activation instead of starting a duplicate resume. - await expect(followup(ctx, parent, started.childId, message('second follow-up'))) - .rejects.toThrow(/not delivered/) - releaseLoad() - const snapshot = await waitTerminal(ctx, first.taskId, parent) - expect(snapshot.status).toBe('completed') - // Exactly one follow-up Task was created. - expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId]) - }) -}) - -describe('service disposal with live activations', () => { - it('cancels and settles a starting activation on service disposal instead of stranding it', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-')) - roots.push(root) - await ctx.plugin(JsonlSessionPersistence, { root }) - await ctx.plugin(AgentLoop, { agents: [] }) - const subagentsFiber = await ctx.plugin(SubagentService) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) - // A provider that stays pending until its signal aborts, so the activation - // is observably mid-start when the subagent service is disposed. - let sawAbort = false - ctx.subagents.registerProvider({ - name: 'pending', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: request => new Promise((_resolve, reject) => { - request.signal.addEventListener('abort', () => { - sawAbort = true - reject(new Error('startup aborted')) - }, { once: true }) - }), - resume: () => Promise.reject(new Error('unreachable')), - }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - - const started = ctx.subagents.startContinuable({ - provider: 'pending', - label: 'will be interrupted', - request: { prompt: message('go'), parent }, - }) - // LocalTaskService keeps the producer Task; the disposing subagent service must - // cancel its activation and await settlement rather than strand it. - await subagentsFiber.dispose() - expect(sawAbort).toBe(true) - const snapshot = await waitTerminal(ctx, started.taskId, parent) - expect(snapshot.status).toBe('killed') - }) -}) - -describe('outcome mapping helpers', () => { - it.each([ - ['completed', { status: 'completed', output: 'partial' }], - ['aborted', { status: 'killed' }], - ['error', { status: 'failed', detail: 'error' }], - ['max-tokens', { status: 'failed', detail: 'max-tokens' }], - ['refusal', { status: 'failed', detail: 'refusal' }], - ['paused', { status: 'failed', detail: 'paused' }], - ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { - const output = [{ type: 'text' as const, text: 'partial' }] - await expect(settleRun({ - id: SessionId('child'), - localAgent: undefined, - result: Promise.resolve({ output, stopReason: stopReason as never }), - dispose: () => Promise.resolve(), - })).resolves.toEqual(expected) - }) - - it('settleRun disposes the run before reporting, on both result paths', async () => { - const order: string[] = [] - const completed = await settleRun({ - id: SessionId('child-1'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), - dispose() { order.push('dispose'); return Promise.resolve() }, - }) - order.push('reported') - expect(completed).toEqual({ status: 'completed', output: 'ok' }) - expect(order).toEqual(['dispose', 'reported']) - - // An infrastructure rejection still disposes and reports failed. - let disposed = false - const failed = await settleRun({ - id: SessionId('child-2'), - localAgent: undefined, - result: Promise.reject(new Error('transport gone')), - dispose() { disposed = true; return Promise.resolve() }, - }) - expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) - expect(disposed).toBe(true) - - const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' - const durabilityFailed = await settleRun({ - id: SessionId('child-3'), - localAgent: undefined, - result: Promise.reject(new HarnessError( - durabilityMessage, - 'DURABILITY_FAILED', - { cause: new Error('disk full') }, - )), - dispose: () => Promise.resolve(), - }) - expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) - - const disposeFailed = await settleRun({ - id: SessionId('child-4'), - localAgent: undefined, - result: Promise.resolve({ output: [], stopReason: 'completed' }), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) - - const bothFailed = await settleRun({ - id: SessionId('child-5'), - localAgent: undefined, - result: Promise.reject(new Error('result failed')), - dispose: () => Promise.reject(new Error('reap failed')), - }) - expect(bothFailed).toEqual({ - status: 'failed', - detail: 'Error: result failed; dispose failed: Error: reap failed', - }) + void parent }) }) From 357f317b4cddab70010a5c8e2d14b056489b5680 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:33:46 +0800 Subject: [PATCH 03/66] test(subagent): update service, send_message, and delegation specs The continuable path has no Task, so send_message reports a queued next turn and continuable delegation returns only the durable child id. Pins that a follow-up queues behind an open turn rather than steering it, and that a non-parent caller is rejected. Also makes startContinuable/followup reject rather than throw synchronously when continuation services are absent, so callers have one failure mode. --- packages/subagent/subagent/src/index.ts | 6 +- .../subagent/subagent/tests/service.spec.ts | 31 ++-- .../tests/tool-subagent-control.spec.ts | 133 ++++++++---------- .../tool-subagent/tests/tool-subagent.spec.ts | 37 +++-- 4 files changed, 101 insertions(+), 106 deletions(-) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0bfebc8cf5..3be59f2266 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -198,7 +198,7 @@ export class SubagentService extends Service { * @returns the durable child id and the accepted prompt's message id. * @throws when continuation services are unavailable or materialization fails. */ - startContinuable(spec: ContinuableStartSpec): Promise { + async startContinuable(spec: ContinuableStartSpec): Promise { return this.requireContinuations().startContinuable(spec) } @@ -217,7 +217,7 @@ export class SubagentService extends Service { * @throws when continuation services are unavailable, authority is rejected, * or the message was not admitted. */ - followup( + async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], @@ -342,7 +342,7 @@ export class SubagentService extends Service { private requireContinuations(): SubagentContinuationManager { if (this.continuations === undefined) { throw new SubagentError( - 'continuable subagents require the tasks and agents services', + 'continuable subagents require the agents service', 'CONTINUATION_UNAVAILABLE', ) } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e86de737ed..8b68e9554f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -12,7 +12,6 @@ import SubagentService, { assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, - type SubagentProviderStartRequest, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -38,7 +37,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 - lastRequest: SubagentProviderStartRequest | undefined + lastRequest: SubagentStartRequest | undefined constructor( readonly name: string, @@ -49,7 +48,7 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: SubagentProviderStartRequest): Promise { + async start(request: SubagentStartRequest): Promise { this.startCount += 1 this.lastRequest = request return { @@ -112,21 +111,27 @@ describe('SubagentService', () => { const request = baseRequest() await subagents.start('one-shot', request) + // One-shot start borrows the caller's exact request; the seam has no + // provider-facing resume or steer surface to dispatch through. expect(provider.lastRequest).toBe(request) - expectTypeOf() - .not.toExtend[1]>() + expectTypeOf[1]>().toExtend() expect('resume' in subagents).toBe(false) + expect('resume' in provider).toBe(false) }) - it('rejects Task-backed continuation operations when their runtime services are absent', async () => { + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() - expect(() => { - subagents.startContinuable({ - provider: 'unused', - label: 'work', - request: baseRequest(), - }) - }).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' })) + await expect(subagents.startContinuable({ + provider: 'unused', + request: baseRequest(), + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) + await expect(subagents.followup( + { kind: 'user' }, + SessionId('child'), + [{ type: 'text', text: 'hello' }], + { source: { kind: 'user' }, signal: new AbortController().signal }, + )).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) }) it.each([ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b035fa1127..3b1225f63e 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -10,8 +10,6 @@ import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as tool from '../src/index.ts' @@ -31,8 +29,6 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) - await ctx.plugin(LocalTaskService) - await ctx.plugin(ToolTasks, {}) await ctx.plugin(tool) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) @@ -61,6 +57,13 @@ function callTool( }) } +/** Wait until a child's Activation released its handle. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} + describe('dsh-tool-subagent-control', () => { it('registers send_message once, globally, with the two required parameters', async () => { const { ctx } = await setup([]) @@ -68,90 +71,62 @@ describe('dsh-tool-subagent-control', () => { expect(schemas).toHaveLength(1) const props = (schemas[0]!.parameters as { properties?: Record }).properties ?? {} expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id']) - expect(schemas[0]!.description).toContain('task_output') + // The continuable path has no Task, so the schema must not promise one. + expect(schemas[0]!.description).not.toContain('task_output') + expect(schemas[0]!.description).not.toContain('task id') + // Follow-up ordering is model-visible: it cannot redirect the open turn. + expect(schemas[0]!.description).toContain('next turn') }) - it('cold-resumes a settled child and renders the started route with its task id', async () => { + it('cold-resumes a settled child and reports the queued next turn', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagents.startContinuable({ + const started = await ctx.subagents.startContinuable({ provider: 'spawn', - label: 'work', request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + signal: testToolSignal, }) - await ctx.tasks.wait(started.taskId, 5_000, parent) + await waitNoActivation(ctx, started.childId) const result = await callTool(ctx, 'send_message', { subagent_id: started.childId, message: 'and then?', }, parent) + expect(result.isError).toBe(false) - expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`) - const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent) - expect(text(collected)).toBe('second answer\n[status: completed]') + expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) - const followUp = loaded.events.findLast(event => - event.type === 'user/message', - ) + const followUp = loaded.events.findLast(event => event.type === 'user/message') + // Durable provenance records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ kind: 'coordinator', senderSessionId: parent.id, }) }) - it('renders the steered route when the child is still running', async () => { - // Script the child's single turn as two steps: the steer joins mid-turn. - const { ctx, parent } = await setup([]) - let steered: string | undefined - let source: unknown - // Reach past the tool into the subagent service to fake a running route - // deterministically: the tool is a thin adapter, so its steered wording is - // what this test pins. - ctx.subagents.followup = async (agent, _childId, message, options) => { - steered = (message[0] as { text: string }).text - source = options.source - return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } - } + it('queues behind an open turn instead of joining it', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const started = await ctx.subagents.startContinuable({ + provider: 'spawn', + request: { prompt: [{ type: 'text', text: 'long work' }], parent }, + signal: testToolSignal, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const result = await callTool(ctx, 'send_message', { - subagent_id: 'some-child', + subagent_id: started.childId, message: 'also consider Y', }, parent) expect(result.isError).toBe(false) - expect(steered).toBe('also consider Y') - expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id }) - expect(text(result)).toBe('message delivered to running task subagent-9') - }) - it('cancels a pending live-delivery wait when the tool signal aborts', async () => { - const { ctx, parent, adapter } = await setup(['hang']) - const started = ctx.subagents.startContinuable({ - provider: 'spawn', - label: 'hung work', - request: { prompt: [{ type: 'text', text: 'wait' }], parent }, - }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() - const followup = ctx.subagents.followup.bind(ctx.subagents) - ctx.subagents.followup = (agent, childId, message, options) => { - const delivery = followup(agent, childId, message, options) - deliveryStarted.resolve() - return delivery - } - - const controller = new AbortController() - const execution = callTool(ctx, 'send_message', { - subagent_id: started.childId, - message: 'follow up', - }, parent, controller.signal) - await deliveryStarted.promise - controller.abort('parent tool cancelled') - - const result = await execution - expect(result.isError).toBe(true) - expect(result.error?.info?.code).toBe('CANCELLED') - expect(ctx.agents.get(started.childId)).toBeUndefined() - const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent) - expect(snapshot.status).toBe('killed') + await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) + const prompts = loaded.events.flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + // A follow-up is its own later turn, never steering inside the first one. + expect(prompts).toEqual(['long work', 'also consider Y']) expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) }) @@ -161,17 +136,26 @@ describe('dsh-tool-subagent-control', () => { subagent_id: 'no-such-child', message: 'hello?', }, parent) - // Unknown ids start a Task whose failure carries the unavailable detail; - // synchronous rejections (ownership conflicts) become isError results. - if (result.isError) { - expect(text(result)).toContain('not delivered') - } else { - const taskId = text(result).match(/task (\S+) /)?.[1] - expect(taskId).toBeDefined() - const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) - expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('unavailable') - } + expect(result.isError).toBe(true) + expect(text(result)).toContain('unavailable') + }) + + it('rejects a caller that is not the child\'s durable direct parent', async () => { + const { ctx, parent } = await setup([textResponse('first')]) + const started = await ctx.subagents.startContinuable({ + provider: 'spawn', + request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + signal: testToolSignal, + }) + await waitNoActivation(ctx, started.childId) + const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + + const result = await callTool(ctx, 'send_message', { + subagent_id: started.childId, + message: 'mine now', + }, stranger) + expect(result.isError).toBe(true) + expect(text(result)).toContain('another parent session') }) it('fails loud when invoked without a calling agent', async () => { @@ -186,7 +170,6 @@ describe('dsh-tool-subagent-control', () => { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(LocalTaskService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) await fiber.dispose() diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1b45640a94..fdcc359447 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { } describe('dsh-tool-subagent', () => { - it('rejects continuable background policy when the configured provider cannot resume', async () => { + it('rejects continuable background policy when the provider cannot prepare continuable children', async () => { let failure: unknown try { await setup({ @@ -668,10 +668,10 @@ describe('dsh-tool-subagent background mode', () => { return ctx } - it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => { + it('keeps a continuable-capable provider one-shot when backgroundMode selects one-shot', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') - let resumeCalls = 0 + let prepareCalls = 0 ctx.subagents.registerProvider({ name: 'resumable', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -685,9 +685,9 @@ describe('dsh-tool-subagent background mode', () => { }), dispose: () => Promise.resolve(), }), - resume: async () => { - resumeCalls += 1 - throw new Error('one-shot policy must not resume') + prepareContinuable: async () => { + prepareCalls += 1 + throw new Error('one-shot policy must not prepare a continuable child') }, }) tool.apply(ctx, { @@ -706,7 +706,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(started)).toBe('started background subagent task subagent-1') - expect(resumeCalls).toBe(0) + expect(prepareCalls).toBe(0) }) it('returns a task id immediately and the answer is collected through task_output', async () => { @@ -899,10 +899,13 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } - it('starts a continuable child and returns both ids without send_message', async () => { + it('starts a continuable child and returns only its durable id, creating no Task', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! - expect(schema.description).not.toContain('send_message') + // Continuable delegation has no Task, so the schema promises no collection. + expect(schema.description).not.toContain('task_output') + expect(schema.description).not.toContain('task_kill') + expect(schema.description).toContain('send_message') const started = await callSubagent( ctx, @@ -910,15 +913,19 @@ describe('dsh-tool-subagent continuable background mode', () => { { agent: parent }, ) expect(started.isError).toBe(false) - const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started)) + const match = /^started subagent (\S+)$/.exec(text(started)) expect(match).not.toBeNull() - const [, childId, taskId] = match! - const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent) - expect(snapshot.status).toBe('completed') - expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer') - // The child id names a durable session that outlives the settled Task. + const [, childId] = match! + // No Task was created for the continuable child. + expect(ctx.tasks.list(parent)).toEqual([]) + + await vi.waitFor(() => { + expect(ctx.agents.get(SessionId(childId!))).toBeUndefined() + }, { timeout: 5_000 }) + // The child id names a durable session carrying its continuation descriptor. const loaded = await ctx.sessionPersistence.load(SessionId(childId!)) expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) }) From 4e7a5f19cf6e7310f39b68bdfb65c0af1151c16c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:37:22 +0800 Subject: [PATCH 04/66] docs(subagent): rewrite the subagent data-structure doc for activations wip: Chinese pair and remaining generated catalogs follow. --- docs/core-data-structures/subagent.md | 267 ++++++++++++++------------ scripts/gen-tool-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 29 +-- 3 files changed, 162 insertions(+), 137 deletions(-) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 8f24afec47..a58ecf13ba 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,24 +4,25 @@ English | [中文](subagent.zh.md) The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. -Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal Task-backed manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## Two kinds of capability, discovered two ways -A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features are instead optional methods whose presence IS the capability, with TS narrowing as the discovery mechanism: confirmed live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). +A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-seam-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider). ```ts type-equiv /** * 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 * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -31,16 +32,16 @@ interface SubagentCapabilities { } ``` -## The start request +## The one-shot start request The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -94,31 +95,41 @@ interface SubagentStartRequest { `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. -Providers receive a separate resolved shape. The `SubagentService.start()` parameter type excludes continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. +Providers receive exactly this request: one-shot delegation has no service-resolved continuation state, because a continuable child never reaches `SubagentProvider.start()`. -```ts type-equiv -/** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. - */ -interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} +## 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. + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations ``` -## Continuable children and provider resume +`SubagentService.startContinuable()` reserves the stable child id, snapshots the versioned `subagent/descriptor` payload, asks the named provider for its detached `ContinuableCreateSpec`, creates the child Agent through a private activation-owner scope, establishes any continuable-parent ownership, and submits the initial prompt. It resolves with `{ childId, messageId }` when inbox acceptance yields the message id — without waiting for the turn to start or for the message to enter the Session log. Every failure before that acceptance rejects with neither id, disposing any created handle and rolling back the Activation and parent ownership. -A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the provider-facing start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.followup()` mirrors the intent verb on `Agent`: it steers a live activation or privately dispatches a resolved provider resume after loading and authorizing a stopped child. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `followup()` reports whether the content `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal through one options object; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. +`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 | + +`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). + +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. + +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`; only a trusted host adapter can supply user authority. `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. + +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. + +Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens 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. + +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` is the lifecycle-wide stop path: it closes admission synchronously, then disposes every live Activation forest child-first, awaiting every branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -131,80 +142,94 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * Options for following up with one continuable child. + * 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. */ + | { readonly kind: 'user' } +``` + +```ts type-equiv +/** Options for following up with one continuable child. */ interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } ``` ```ts type-equiv -/** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. - */ -type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } -``` - -```ts type-equiv -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ - readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData +/** Identities returned once a continuable child accepted its initial prompt. */ +interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } ``` ```ts type-equiv /** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. + * 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. */ -interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ +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 +/** + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. + */ +interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData } ``` -The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (an activation's result contract, not durable composition). The `subagent/descriptor` event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. +```ts type-equiv +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] +} +``` + +The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) snapshots explicit fields — provider name, resolved child `agentOptions.provider`/`model`, optional `persona`/`toolFilter` — never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (a one-shot result contract, not durable composition). The continuation manager appends the model-hidden `subagent/descriptor` event after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary, so descriptor lookup reads the child's own suffix. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. ## The terminal result: `SubagentResult` -The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. +The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv /** @@ -249,15 +274,18 @@ interface SubagentStopReasonMap { } ``` -## A live run: `SubagentRun` +## A one-shot run: `SubagentRun` -`SubagentRun` is the consumer-owned handle for a ready child — one disposable activation, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional confirmed `steer` method advertises live delivery by presence and fulfills only after a request snapshot admits the message. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. +`SubagentRun` is the consumer-owned handle for a ready one-shot child — one disposable foreground delegation with one result, never a durable child handle. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. A run has no steering and no resume: continuable conversations have no run at all, because the continuation manager holds their `AgentHandle` directly and orders every turn through the child's own inbox. ```ts type-equiv /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ interface SubagentRun { /** @@ -276,10 +304,8 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -287,25 +313,14 @@ interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } ``` -A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. +A local one-shot run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. ## The provider seam: `SubagentProvider` -Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. +Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`, and rejects a continuable start on a provider without `prepareContinuable`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv /** @@ -325,33 +340,37 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } ``` -Provider `start()` fulfills only with a ready run; provider `resume()` shares the same publication and lifecycle-observation contract but is dispatched only by the continuation manager. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed -The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: +The spawn and fork backends create an ordinary one-shot agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`; a continuable child is instead created by the continuation manager through its own activation-owner scope. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: -- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. -- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). +- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. +- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 96d6a97457..56b10b84b3 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -107,7 +107,8 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), - resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), + // Declared so consumers configured for continuable background mode mount. + prepareContinuable: () => Promise.reject(new Error('tool-catalog provider cannot prepare a child')), } ctx.subagents.registerProvider(provider) } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0a17861a07..e9b60aa650 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,16 +1094,6 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentProviderStartRequest", - "source": "packages/subagent/subagent/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentContinuation", - "source": "packages/subagent/subagent/src/types.ts" - }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "CoordinatorMessageSource", @@ -1116,12 +1106,27 @@ }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentFollowupResult", + "symbol": "SubagentAuthority", "source": "packages/subagent/subagent/src/continuation.ts" }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentProviderResumeRequest", + "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", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "ContinuableCreateSpec", "source": "packages/subagent/subagent/src/types.ts" }, { From 72f8f4733532d5674eba06dd8e1d71882967477b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:40:45 +0800 Subject: [PATCH 05/66] docs(subagent): land the continuable-subagents note and supersede its predecessors Moves the RFC to implemented/, restates it as current-state prose under the implemented note format, and records what the Task-backed continuable-subagents note and the two subagent-service simplification notes retain versus what this record replaces. --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- ...ntinuable-subagent-conversations.i18n.yaml | 6 +++++ ...7-28-continuable-subagent-conversations.md | 26 ++++++++++--------- ...8-continuable-subagent-conversations.zh.md | 26 ++++++++++--------- ...6-merge-subagent-control-service.i18n.yaml | 4 +-- ...26-07-26-merge-subagent-control-service.md | 2 +- ...07-26-merge-subagent-control-service.zh.md | 2 +- ...subagent-continuation-operations.i18n.yaml | 4 +-- ...-named-subagent-continuation-operations.md | 2 ++ ...med-subagent-continuation-operations.zh.md | 2 ++ ...ntinuable-subagent-conversations.i18n.yaml | 6 ----- 13 files changed, 48 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml rename .agents/notes/{proposed => implemented}/feature/2026-07-28-continuable-subagent-conversations.md (94%) rename .agents/notes/{proposed => implemented}/feature/2026-07-28-continuable-subagent-conversations.zh.md (93%) delete mode 100644 .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 8813e2d10b..0694137ab0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -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-21-continuable-background-subagents.md -2026-07-21-continuable-background-subagents.md: 0ea085a3eb9c6e661c1f009f338b264c06f14983 -2026-07-21-continuable-background-subagents.zh.md: 93f4c5b8ba4a052c5a6bb6eac3802601eb0797a5 +2026-07-21-continuable-background-subagents.md: 513ee668a9e04c05bb50f946016c460e09d1ddcd +2026-07-21-continuable-background-subagents.zh.md: 88cdd00582b18a2092c6993507fe3d4b92f237ae diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 0ea085a3eb..513ee668a9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-21-continuable-background-subagents.zh.md) -The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force. +This record is superseded by [Continuable subagents](2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed activation model, routing, cancellation, and durability semantics with one durable Session plus at most one process-local Activation. Its service-placement and provider-capability policy were already superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). Only the durable child-session and descriptor rationale remains in force. ## Problem diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index 93f4c5b8ba..88cdd00582 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-21-continuable-background-subagents.md) | 中文 -本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效。 +本记录已由[可继续的 subagent](2026-07-28-continuable-subagent-conversations.md)取代——后者以一个持久 Session 加至多一个进程内 Activation(驻留期)替换了其基于 Task 的 activation 模型、路由、取消和持久性语义。其服务放置与提供方功能策略此前已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。仅持久 child 会话与 descriptor 的设计依据仍然有效。 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml new file mode 100644 index 0000000000..f9a3bfc16c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +2026-07-28-continuable-subagent-conversations.md: 5ab17ea13d15d66afab4fee6766b082dd207b8a3 +2026-07-28-continuable-subagent-conversations.zh.md: eb14ebcec9682432682f6b5b4d8399f35b6882a2 diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md similarity index 94% rename from .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md rename to .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 3902fbc330..5ab17ea13d 100644 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -1,6 +1,6 @@ # Agent Note: Continuable subagents -Status: proposed +Status: implemented English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) @@ -16,7 +16,7 @@ The runtime lifetime is also wider than one turn. A subagent can finish its own 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. -## Proposal +## Decision A continuable subagent has one durable Session and at most one process-local Activation: @@ -34,7 +34,7 @@ The continuation manager owns activation admission, authority checks, the live o ### Materialization and public operations -The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `AgentMessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. +The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `MessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. @@ -44,9 +44,9 @@ 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 `AgentMessageId`, and neither reports how the manager materialized the Activation. +`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. -For start and follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance. After the operation returns its `AgentMessageId`, the manager owns the Activation independently; later caller cancellation does not cancel the accepted turn or dispose the child. +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. ### Durable Session and live Activation @@ -95,7 +95,7 @@ Routing depends only on Activation residency: | `waiting` | parent or user | wake the same Activation | | no Activation | parent or user | 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 `AgentMessageId`, 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. +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. ### Child ownership @@ -171,22 +171,24 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **Expose subagent steering in the MVP.** 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. -**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 `AgentMessageId` and the existing inbox events keeps delivery correlation on the Agent contract that owns it. +**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. **Use a child reference count.** A count cannot identify which child still owns teardown work and permits duplicate decrement errors. An identity set retains cancellation and disposal obligations explicitly. -## Acceptance criteria +## Consequences + +The implementation pins these behaviors: - A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. - `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. -- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `AgentMessageId`, without waiting for turn start or a Session-log write. +- The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `MessageId`, without waiting for turn start or a Session-log write. - Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. - Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - 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. -- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `AgentMessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result. +- `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. - The MVP 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. - The MVP exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. @@ -198,10 +200,10 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. -- Unit coverage pins the residency-only routing table, single-inbox ordering, `AgentMessageId` 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. +- 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. -## Risks +### Accepted costs Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md rename to .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 11f59d8f1a..eb14ebcec9 100644 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -1,6 +1,6 @@ # Agent Note(agent 决策记录):可继续的 subagent -Status: proposed +Status: implemented [English](2026-07-28-continuable-subagent-conversations.md) | 中文 @@ -16,7 +16,7 @@ Status: proposed 用户和 parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 child 发送后续工作。将每条继续执行消息作为 follow-up 排队,可以让两类发送方遵循同一项排序规则。 -## 提案 +## 决策 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: @@ -34,7 +34,7 @@ persisted Session ### 物化与公开操作 -具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `AgentMessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 +具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `MessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 @@ -44,9 +44,9 @@ 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 都返回已接受的 `AgentMessageId`,两者都不报告管理器如何物化激活。 +`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`,两者都不报告管理器如何物化激活。 -对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `AgentMessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 +对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 ### 持久化会话与在线激活 @@ -95,7 +95,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( | `waiting` | parent 或 user | 唤醒同一激活 | | 无激活 | parent 或 user | 冷恢复新激活 | -继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `AgentMessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 +继续执行层不定义单独的投递路由结果。成功投递 `ctx.subagents.followup()` 或 `send_message` 时会返回已接受的 `MessageId`,投递失败则会抛出异常。现有的 `agent/inbox/enqueue`、`agent/inbox/dequeue` 和 `agent/inbox/discard` 事件仍用于观测消息生命周期;适配器可以呈现通用的接受确认,但不暴露 `started`、`queued`、`resumed` 或其他 subagent 专属路由词汇。 ### child 所有权 @@ -171,22 +171,24 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 -**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `AgentMessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 +**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 **使用 child 引用计数。** 计数无法识别哪个 child 仍持有拆卸工作,也允许重复递减错误。身份集合会显式保留取消和 dispose 义务。 -## 验收标准 +## 影响 + +本实现固定了以下行为: - 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 - `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 -- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `AgentMessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 +- 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `MessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 - 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 - 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 - 用户可以在不加载历史 parent 的前提下冷恢复持久化 child。 - `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 - Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 -- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `AgentMessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 +- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 - MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 - MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 @@ -198,10 +200,10 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 -- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `AgentMessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 +- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派、parent 与用户混合的 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 -## 风险 +### 已接受的代价 移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml index f28c1f6a8e..daf11549ed 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md -2026-07-26-merge-subagent-control-service.md: 84995446939d0f47e008bffb38083b1b6e0706de -2026-07-26-merge-subagent-control-service.zh.md: 7f82555159bfea9e00fa4cc2afdcf30382f3f776 +2026-07-26-merge-subagent-control-service.md: 67a26b6014efeb4f35911ccb90980d85f5e67557 +2026-07-26-merge-subagent-control-service.zh.md: 532a93e7c027ae18417e7aa4ae41faee21b5575a diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md index 8499544693..67a26b6014 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-merge-subagent-control-service.zh.md) -The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md). +The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md) and again by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which keeps the single merged service while removing provider `resume` dispatch and the Task-backed continuation lifecycle. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md index 7f82555159..532a93e7c0 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-merge-subagent-control-service.md) | 中文 -公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化。 +公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化,并由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)再次细化——后者保留这一个合并后的服务,同时移除提供方 `resume` 派发和基于 Task 的继续执行生命周期。 ## 问题 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index 5623e559bc..a8b249fae0 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 1155e6b2fb89661021ebdbd6310902e74a500078 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 5f434cd8fbb171ef77a3b1f307029d6ade09f1d6 +2026-07-27-intent-named-subagent-continuation-operations.md: 9f29074add3517d0baf94516c56fa69085ef75c4 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: a748af1a6cf44bc552b492d43314bf5a4e95338d diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 1155e6b2fb..9f29074add 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) +The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, its bare-`Agent` parameter with an explicit authority union, and provider `resume` dispatch with `prepareContinuable`. + ## Problem Merging continuable-child orchestration into `ctx.subagents` left provider dispatch and caller intent on the same public service. `resume(name, request)` accepted a descriptor, authorized parent, durable child id, and activation signal that only the internal continuation manager could resolve correctly. `sendMessage(...)` exposed transport wording rather than the `followup` intent already used by `Agent`, and its separate source and signal parameters widened an operation every caller had to use atomically. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 5f434cd8fb..a748af1a6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 +本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,以显式的 authority(授权)联合类型替换裸 `Agent` 参数,并以 `prepareContinuable` 替换提供方 `resume` 派发。 + ## 问题 将可继续 child 的编排合并到 `ctx.subagents` 后,提供方分发与调用方意图共存于同一个公开服务中。`resume(name, request)` 接受描述符、已鉴权的 parent、持久化 child id 与激活信号,而只有内部继续执行管理器才能正确解析这些数据。`sendMessage(...)` 暴露的是传输层措辞,而不是 `Agent` 已采用的 `followup` 意图;它还将来源与信号拆成独立参数,扩大了操作接口,而每个调用方都必须以原子方式同时使用二者。 diff --git a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml deleted file mode 100644 index 4ef20ef978..0000000000 --- a/.agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: 3902fbc33004219f98d070d4b898de6b2c19d40d -2026-07-28-continuable-subagent-conversations.zh.md: 11f59d8f1a57e2d1bf375a3e1c1cd46043c60a3f From ae6976cbbd6d4ac0dba8c90b7880045aa00ca388 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:41:59 +0800 Subject: [PATCH 06/66] docs(subagent): regenerate catalogs for the activation-based seam Adds the new continuable types to the cordis-catalog type-link map and regenerates the cordis api/service/event catalogs, tool catalog, config catalog, and doc graphs. --- docs/config-catalog.md | 8 +-- docs/cordis-catalog/events.md | 8 +-- docs/cordis-catalog/services.md | 59 +++++++++++------ docs/event-producer-consumer.md | 10 +-- docs/tool-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 64 ++++++++++--------- scripts/gen-cordis-catalog.ts | 7 +- 7 files changed, 93 insertions(+), 65 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 33bd5777e6..29ba89cbef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1582,7 +1582,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork/src/index.ts:30`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:31`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1894,9 +1894,9 @@ export interface Config { */ enableRunInBackground?: boolean /** - * Background execution policy (default `one-shot`). `continuable` requires - * a provider with persisted resume support and returns both child and Task - * ids; follow-up adapters remain independently optional. + * Background execution policy (default `one-shot`). `continuable` requires a + * provider with the `prepareContinuable` capability and returns the durable + * child id; follow-up adapters remain independently optional. */ backgroundMode?: 'one-shot' | 'continuable' /** diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c7b0734585..877a6a44f2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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:166`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../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:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:114`](../../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:146`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:120`](../../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:157`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a1ff16cb7e..11ba7fb59b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1949,30 +1949,53 @@ Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/stora ## `ctx.subagents` — `SubagentService` -Named provider registry with raw and Task-backed continuation operations. +Named provider registry with one-shot runs and continuable-child operations. ```ts cordis-catalog /** - * Start one durable continuable child through a Task-backed initial - * activation. - * @param spec - provider, Task label, and delegation request. - * @returns the stable child id and initial activation Task id. + * Establish one durable continuable child and deliver its initial prompt. + * Resolves 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 back the child entirely. + * @param spec - provider, delegation request, and caller cancellation. + * @returns the durable child id and the accepted prompt's message id. + * @throws when continuation services are unavailable or materialization fails. */ -startContinuable(spec: ContinuableStartSpec): ContinuableStart +async startContinuable(spec: ContinuableStartSpec): Promise /** - * Follow up with a continuable child. A live child is steered and fulfillment - * confirms request admission; an idle child immediately returns a fresh Task - * whose descriptor lookup, authorization, and cold resume may later fail. - * @param parent - live direct parent authorizing the operation. + * 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. * @param childId - durable child session id. * @param content - user-role content to deliver. - * @param options - durable attribution and caller cancellation; aborting a - * live-delivery wait cancels the shared activation and awaits quiescence. - * @returns the existing steered Task or newly started Task. - * @throws when continuation services are unavailable or live delivery is not admitted. + * @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. */ -followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise + +/** + * 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 + +/** + * 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. + * @returns once every live Activation released its `AgentHandle`. + * @throws an aggregate error after all branches settle when any failed. + */ +async drainContinuable(): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -2005,12 +2028,12 @@ list(): string[] * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ -async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise +async start(name: string, request: SubagentStartRequest): Promise ``` -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) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentFollowupResult](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +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) -Source: [`packages/subagent/subagent/src/index.ts:199`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:173`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 33f004e102..696f4f8f66 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:421`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | @@ -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:166`](../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:140`](../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:146`](../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:157`](../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:140`](../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:114`](../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:120`](../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:131`](../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`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 87e9a2a526..f3a3f87e3a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1151,7 +1151,7 @@ The registered tool name is the load-time `toolName` config (default `subagent`) ### `send_message` -Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. +Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. ```json { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6acad85629..0cf46786e3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -882,15 +882,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'Named provider registry with raw and Task-backed continuation operations.', + summary: 'Named provider registry with one-shot runs and continuable-child operations.', methods: [ { - signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', - jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', + signature: 'async startContinuable(spec: ContinuableStartSpec): Promise', + 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: 'followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', - jsDoc: '/**\n * Follow up with a continuable child. A live child is steered and fulfillment\n * confirms request admission; an idle child immediately returns a fresh Task\n * whose descriptor lookup, authorization, and cold resume may later fail.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable attribution and caller cancellation; aborting a\n * live-delivery wait cancels the shared activation and awaits quiescence.\n * @returns the existing steered Task or newly started Task.\n * @throws when continuation services are unavailable or live delivery is not admitted.\n */', + signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', + 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: '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 drainContinuable(): Promise', + jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', @@ -905,7 +913,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List registered provider names in insertion order.\n * @returns the registered names.\n */', }, { - signature: 'async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise', + signature: 'async start(name: string, request: SubagentStartRequest): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, ], @@ -1567,6 +1575,10 @@ 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}', @@ -1791,13 +1803,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContinuableCreateRequest', + declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'ContinuableCreateSpec', + declaration: 'export interface ContinuableCreateSpec {\n readonly seed?: readonly SessionEvent[];\n}', + }, { name: 'ContinuableStart', - declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly taskId: TaskId;\n}', + declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly messageId: MessageId;\n}', }, { name: 'ContinuableStartSpec', - declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly request: Omit;\n readonly signal: AbortSignal;\n}', }, { name: 'CreateAgentOptions', @@ -2671,37 +2691,21 @@ 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};', + }, { name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, - { - name: 'SubagentContinuation', - declaration: 'export interface SubagentContinuation {\n readonly sessionId: SessionId;\n readonly descriptor: SubagentDescriptorData;\n}', - }, - { - name: 'SubagentDescriptorData', - declaration: 'export interface SubagentDescriptorData {\n readonly version: number;\n readonly provider: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', - }, { name: 'SubagentFollowupOptions', declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', }, - { - name: 'SubagentFollowupResult', - declaration: 'export type SubagentFollowupResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', - }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentProviderStartRequest): Promise;\n resume?(request: SubagentProviderResumeRequest): Promise;\n}', - }, - { - name: 'SubagentProviderResumeRequest', - declaration: 'export interface SubagentProviderResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', - }, - { - name: 'SubagentProviderStartRequest', - declaration: 'export interface SubagentProviderStartRequest extends SubagentStartRequest {\n readonly continuation?: SubagentContinuation | undefined;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n prepareContinuable?(request: ContinuableCreateRequest): Promise;\n}', }, { name: 'SubagentResult', @@ -2709,7 +2713,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[], source: MessageSource): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f7ab385afd..faaafc4c11 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,14 +160,15 @@ export const LINK_MAP: Readonly> = { 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', - SubagentFollowupResult: 'subagent.md', SubagentProvider: 'subagent.md', - SubagentProviderResumeRequest: 'subagent.md', - SubagentProviderStartRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', From 694b0783650d765449d82b37ca3900c904a11769 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:51:25 +0800 Subject: [PATCH 07/66] docs(subagent): update package READMEs for the activation lifecycle Rewrites the service API table, authority-versus-provenance contract, residency routing, and deferred-work list; scopes the in-process driver README to one-shot runs; and restates both model-facing tools' outputs, which no longer carry a task id. --- examples/acp-agent/tests/acp.snapshot.ts | 8 +- .../fixtures/subagent-durability-failure.ts | 37 +-- .../snapshots/subagent-continuable/input.json | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 4 +- packages/subagent/README.zh.md | 4 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 16 +- .../subagent/subagent-inprocess/README.zh.md | 17 +- .../tests/structured.spec.ts | 24 +- .../tests/subagent-inprocess.spec.ts | 303 +----------------- .../tests/subagent-spawn.spec.ts | 25 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 57 ++-- packages/subagent/subagent/README.zh.md | 57 ++-- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 12 +- .../tool-subagent-control/README.zh.md | 12 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 14 +- packages/subagent/tool-subagent/README.zh.md | 14 +- 21 files changed, 176 insertions(+), 450 deletions(-) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c2e0328fab..853100cf5c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -217,9 +217,11 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns - // both the durable subagent id and its task id, a failed final durability - // confirmation reaches task_output with its diagnosis, and send_message to - // an unknown subagent id starts a follow-up task that settles unavailable. + // only the durable subagent id, two send_message calls queue as later FIFO + // turns on that same child (the parent is never woken with their output), + // send_message to an unknown subagent id fails without delivering, and the + // child's retained handle is disposed child-first at teardown despite a + // failed final durability confirmation. { name: 'subagent-continuable', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 47f96c0b80..7829b3812b 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,45 +1,34 @@ import type { Context } from 'cordis' export const name = 'subagent-durability-failure' -export const inject = ['sessionPersistence', 'tasks'] +export const inject = ['sessionPersistence'] const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' -const FOLLOW_UP_TASK_ID = 'subagent-2' /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { - const thirdStepEnded = Promise.withResolvers() - const followUpSettled = Promise.withResolvers() + const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) - // The unavailable-child lookup is real asynchronous I/O. Fence it between - // the authored step boundaries so runner speed cannot reorder the exact log. + // The unavailable-child lookup is real asynchronous I/O. Fence it behind both + // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - if (id === UNKNOWN_CHILD_ID) await thirdStepEnded.promise + if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise return load.call(persistence, id) } ctx.effect(() => () => { persistence.load = load - thirdStepEnded.resolve(undefined) - followUpSettled.resolve(undefined) + followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined - && event.type === 'step/end' - && event.data.turn === 1 - && event.data.step === 3) { - thirdStepEnded.resolve(undefined) - } - }) - ctx.tasks.onTaskDone((snapshot) => { - if (snapshot.id === FOLLOW_UP_TASK_ID) followUpSettled.resolve(undefined) - }) - ctx.on('agent/step', async (agent, turn, step) => { - if (agent.session.header.parentSession === undefined && turn === 1 && step === 4) { - await followUpSettled.promise - } + // Both authored follow-ups reach the child inbox before the unknown-id lookup + // runs, so the queued FIFO order is what the transcript records. + let accepted = 0 + ctx.on('agent/inbox/enqueue', (agent) => { + if (agent.session.header.parentSession === undefined) return + accepted += 1 + if (accepted >= 3) followupsAccepted.resolve(undefined) }) const flushedTurnEnds = new WeakSet() diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json index 7fd4a2c3e4..9566755044 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -8,7 +8,7 @@ }, { "op": "prompt", - "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool." + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool." } ] } diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index bead24d34c..bbaa8070ac 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -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/README.md -README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46 -README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda +README.md: e6e83866e04185ccb1f25187f450ea0e0e549128 +README.zh.md: 9a7ad5c37ce7d09e4f9f4d21c49175506c024f9b diff --git a/packages/subagent/README.md b/packages/subagent/README.md index a195ecbaeb..e6e83866e0 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,7 +6,7 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `ctx.subagents` | +| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and continuable-child orchestration | `ctx.subagents` | | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) | @@ -15,6 +15,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. +The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index b9965030a3..9a7ad5c37c 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,7 +6,7 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` | +| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可继续子 agent 编排 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | @@ -15,6 +15,6 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | | `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口和继续执行编排位于 `subagent/subagent/`。原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation,并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 25b886b635..d190dfd0cf 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -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-inprocess/README.md -README.md: 8d266e93021285e27e7819386a4de9c33492a796 -README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0 +README.md: 0495b7cae003a8c280689c4bfdd991e0f6950569 +README.zh.md: 2e512ffd281c6334db925c97b110934bbcc19eef diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 8d266e9302..0495b7cae0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, confirmed steering, and disposal—has one implementation here. +This package is the shared run driver for the two in-process providers' one-shot delegations. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here. Continuable children never come through this driver: the continuation manager in `@deepseek-ai/dsh-subagent` composes and drives them directly, so this driver owns exactly one turn with one result. ## Start contract @@ -11,28 +11,20 @@ This package is the shared run driver for the two in-process providers. Spawn pa The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. -2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens. -4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. -6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. +2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. +3. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. +4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). -## Cold resume - -`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start. - ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume. - ## Spawn and fork inputs `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 79450a32a7..2e512ffd28 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 +本包是两个进程内提供方一次性委派共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。可继续子 agent 绝不通过本驱动器:`@deepseek-ai/dsh-subagent` 中的继续执行管理器会直接组合并驱动它们,因此本驱动器只拥有一个轮次和一个结果。 ## 启动契约 @@ -11,28 +11,19 @@ 驱动器按以下顺序运行: 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 -2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 -4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 -6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 +2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +3. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 +4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 - -## 冷恢复 - -`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。 - ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。 - ## Spawn 与 fork 输入 `InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index d3396cd21a..ddfaf0a3d6 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -120,28 +120,6 @@ describe('in-process structured output', () => { await run.dispose() }) - it('confirmed steering rejects delivery once the structured result is captured', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), - ]) - // oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable. - let run: Awaited> | undefined - let delivery: Promise | undefined - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined || run === undefined - || event.type !== 'tool/result' || delivery !== undefined) return - delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) - void delivery?.catch(() => undefined) - }) - run = await ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - if (delivery === undefined) throw new Error('structured result did not submit steering') - await expect(delivery) - .rejects.toThrow(/already reported its structured result; the message was not delivered/) - expect(result.structured).toEqual({ answer: 7 }) - await run.dispose() - }) - it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => { // One model response carrying structured_output FIRST and a side-effecting // call after it: the continuation veto only fires at step end, so without diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 530720596f..2df336ff47 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -2,17 +2,16 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -39,22 +38,6 @@ function request(parent: Agent, signal = new AbortController().signal) { return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal } } -function continuableRequest(parent: Agent) { - const sessionId = SessionId('continuable-child') - return { - ...request(parent), - continuation: { - sessionId, - descriptor: { - version: SUBAGENT_DESCRIPTOR_VERSION, - provider: 'spawn', - agentProvider: 'mock', - agentModel: 'mock', - }, - }, - } -} - function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } @@ -88,110 +71,7 @@ describe('startInProcessRun', () => { await run.dispose() }) - it('rejects a continuable child when no durability listener is registered', async () => { - const { parent } = await setup([textResponse('driver answer')]) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') - await run.dispose() - }) - - it('rejects when the durability listener disappears before final confirmation', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - let flushes = 0 - let detach = (): void => {} - detach = ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes === 1) detach() - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') - expect(flushes).toBe(1) - await run.dispose() - }) - - it('requires a final durability checkpoint for a continuable child', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const failure = new Error('disk full') - let flushes = 0 - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - throw failure - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - const caught: unknown = await run.result.catch((error: unknown) => error) - expect(caught).toBeInstanceOf(SubagentError) - const durabilityError = caught as SubagentError - expect(durabilityError.code).toBe('DURABILITY_FAILED') - expect(durabilityError.cause).toBe(failure) - expect(durabilityError.message).toContain( - 'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full', - ) - expect(flushes).toBe(2) - await run.dispose() - }) - - it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - let flushes = 0 - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes === 1) throw new Error('temporary append failure') - }) - - const run = await startInProcessRun(continuableRequest(parent), {}) - await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) - expect(flushes).toBe(2) - await run.dispose() - }) - - it.each([ - { checkpoint: 'succeeds', failure: undefined }, - { checkpoint: 'fails', failure: new Error('disk full') }, - ])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => { - const { ctx, parent } = await setup([textResponse('driver answer')]) - const checkpointStarted = Promise.withResolvers() - const releaseCheckpoint = Promise.withResolvers() - let flushes = 0 - ctx.on('session/flush', async (session) => { - if (session.header.parentSession === undefined) return - flushes++ - if (flushes !== 2) return - checkpointStarted.resolve(undefined) - await releaseCheckpoint.promise - if (failure !== undefined) throw failure - }) - const controller = new AbortController() - - const run = await startInProcessRun({ - ...continuableRequest(parent), - signal: controller.signal, - }, {}) - await checkpointStarted.promise - controller.abort() - releaseCheckpoint.resolve(undefined) - - await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) - expect(flushes).toBe(2) - await run.dispose() - }) - - it('keeps foreground runs best-effort when their turn checkpoint fails', async () => { + it('does not add a final durability checkpoint to a foreground run', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) let flushes = 0 ctx.on('session/flush', (session) => { @@ -336,69 +216,18 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - it('rejects an already-aborted resume before publication', async () => { - const { parent } = await setup([]) - const controller = new AbortController() - controller.abort('too late') - await expect(resumeInProcessRun({ - sessionId: SessionId('resumed-child'), - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'user' }, - parent, - signal: controller.signal, - descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, - })).rejects.toThrow('aborted before child publication') - }) - - it('resumes without inventing undeclared agent model options', async () => { - const childId = SessionId('resumed-child') - let flushes = 0 - const child = { - id: childId, - options: {}, - session: new Session(childId), - status: 'idle', - acceptsNextStep: false, - ctx: { - sessions: { - flush: () => { - flushes++ - return Promise.resolve(true) - }, - }, - } as unknown as Context, - send(): void {}, - reserveTurnAdmission: () => undefined, - updateInbox: () => 'not-found', - followup(): void {}, - steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } }, - inject(): void {}, - cancel(): void {}, - whenIdle: () => Promise.resolve(), - } as Agent - let resumedOptions: unknown - const parent = { - ctx: { - agents: { - resume: (options: { agentOptions: unknown }) => { - resumedOptions = options.agentOptions - return Promise.resolve({ agent: child, dispose: () => Promise.resolve() }) - }, - }, - }, - } as unknown as Agent - - const run = await resumeInProcessRun({ - sessionId: childId, - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'plugin', plugin: 'test-coordinator' }, - parent, - signal: new AbortController().signal, - descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, - }) - expect(resumedOptions).toEqual({}) + it('stamps only the resolved depth when neither parent nor request declares a model route', async () => { + // The one-shot analogue of the deleted resume coverage ("resumes without + // inventing undeclared agent model options"): a bare parent with no request + // agentOptions yields a child whose options carry ONLY the stamped depth — + // no provider/model is fabricated, so the child's turn errors for want of a + // route rather than silently adopting one. + const { ctx } = await setup([]) + const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {}) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + expect(child.options).toEqual({ subagentDepth: 1 }) await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) - expect(flushes).toBe(1) await run.dispose() }) @@ -461,104 +290,4 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - - it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - const run = await startInProcessRun(request(parent), {}) - await run.result - await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) - .rejects.toThrow(/not running; the message was not delivered/) - const child = ctx.agents.get(run.id)! - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('confirmed steering rejects when a concluding tool prevents request admission', async () => { - const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})]) - const enteredTool = Promise.withResolvers() - const releaseTool = Promise.withResolvers() - ctx.tools.register(defineContentToolFixture({ - name: 'finalize', - description: 'Finish the child run.', - parameters: {}, - async execute(_args, exec) { - enteredTool.resolve(undefined) - await releaseTool.promise - exec.concludeTurn() - return [{ type: 'text', text: 'final' }] - }, - })) - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredTool.promise - - const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' }) - releaseTool.resolve(undefined) - await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/) - await run.result - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('confirmed steering fulfills only after the next request snapshot admits it', async () => { - const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) - const enteredStopping = Promise.withResolvers() - const releaseStopping = Promise.withResolvers() - let held = false - ctx.on('agent/turn-stopping', (agent) => { - if (agent.session.header.parentSession === undefined || held) return - held = true - enteredStopping.resolve(undefined) - return releaseStopping.promise - }) - - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredStopping.promise - - let settled = false - const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' }) - .then(() => { settled = true }) - await Promise.resolve() - expect(settled).toBe(false) - releaseStopping.resolve(undefined) - await delivery - - const result = await run.result - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step') - expect((result.output[0] as { text?: string }).text).toBe('second') - const steering = child.session.events.find(event => event.type === 'steering/message') - expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' }) - await run.dispose() - }) - - it('carries steering from a non-terminal flush window into a tracked next turn', async () => { - const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) - const enteredFlush = Promise.withResolvers() - const releaseFlush = Promise.withResolvers() - let held = false - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined || held) return - if (!session.events.some(event => event.type === 'turn/end')) return - held = true - enteredFlush.resolve(undefined) - return releaseFlush.promise - }) - - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await enteredFlush.promise - expect(child.status).toBe('running') - - const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' }) - releaseFlush.resolve(undefined) - await delivery - const result = await run.result - expect(adapter.requests).toHaveLength(2) - expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - expect((result.output[0] as { text?: string }).text).toBe('second') - await run.dispose() - }) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index aeb6ef8cb7..acb102ea1b 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,19 +235,26 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => { + it('a one-shot run exposes neither steer nor resume; continuable creation is a provider capability', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - // A run represents one disposable activation: cold resume is a provider - // method, never a run method. + // A run is one disposable foreground activation: it has no steering and no + // cold resume. Continuable conversations never become a run — the + // continuation manager drives them through the provider's + // `prepareContinuable` capability instead. + expect('steer' in run).toBe(false) expect('resume' in run).toBe(false) - expect(typeof run.steer).toBe('function') await run.result - // Confirmed live-only contract: after the child settles, delivery fails loud - // rather than falling back to Agent.steer()'s idle queue (which would - // start an untracked turn). - await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) - .rejects.toThrow(/not running; the message was not delivered/) + // The spawn provider DOES advertise continuable creation, and — because a + // spawned child starts fresh — contributes no seed. + const provider = ctx.subagents.getProvider('spawn')! + expect(typeof provider.prepareContinuable).toBe('function') + const spec = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-child'), + parent, + signal: new AbortController().signal, + }) + expect(spec.seed).toBeUndefined() await run.dispose() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0d8b499482..c906868e1d 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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: a484352c486c067058bef806bad3bcd7623cf6cc -README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803 +README.md: fc1eecb7d22c45377d5525ef0247bcf369a441a8 +README.zh.md: 762a027324bc40f159129c3cd4a438d2265fa32b diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index a484352c48..fc1eecb7d2 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -11,8 +11,8 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| | `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, lifecycle events, and continuable-child orchestration. | -| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. | -| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. | +| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. | +| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. | | `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | | `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. | | `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. | @@ -21,35 +21,39 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has six main operations: +`SubagentService` has these operations: | Member | Meaning | |---|---| | `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. | | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | -| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuation state cannot enter through this operation. | -| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | -| `followup(parent, childId, content, { source, signal })` | Follow up with a durable child, matching `Agent.followup()` terminology. It steers the current activation or starts a new Task that cold-resumes the child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `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. | +| `activationState(childId)` | Read one durable child's live residency state (`running`, `waiting`, or `settled`), or `undefined` when no Activation is live. | +| `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. 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. Only the internal continuation manager can add a stable child id and durable descriptor to the provider-facing `SubagentProviderStartRequest`; cold provider resume is likewise private dispatch after descriptor lookup and parent authorization. +`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' }`. 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. 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. ## Capabilities -Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation: +Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported one-shot request before child creation: - `outputSchema` — enforce a structured final result. - `depthLimit` — enforce `maxDepth`. - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. -Runtime features are optional methods whose presence is the capability check: `SubagentRun.steer?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. +Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. ## The durable descriptor -The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before provider dispatch; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. +The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before the child session exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before materialization; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (never captured for a continuable child). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. ## Delegation depth @@ -57,23 +61,35 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. -## Ownership and lifecycle +## One-shot ownership and lifecycle -`provider.start(request): Promise` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation; only the continuation manager dispatches it. +`provider.start(request): Promise` is the ownership-transfer boundary and the only Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after an ordinary start or privately dispatched provider resume has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. +## Continuable children and Activations -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. +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 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. + +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. + +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. 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. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. Collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. ## Model Experience @@ -85,5 +101,8 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **ACP children remain one-shot** — `AcpProvider.resume` 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 provider method's presence. -- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer. +- **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. +- **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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 9a750d5dfa..762a027324 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -11,8 +11,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | 包 | 角色 | |---|---| | `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | -| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容,并支持从持久化存储恢复的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 | | `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | | `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | | `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | @@ -21,35 +21,39 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 服务 API -`SubagentService` 有六个主要操作: +`SubagentService` 具有以下操作: | 成员 | 含义 | |---|---| | `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 | -| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | -| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | +| `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`;冷恢复还要求会话持久化。 | +| `activationState(childId)` | 读取某个持久化子 agent 的实时驻留状态(`running`、`waiting` 或 `settled`);无实时 Activation 时返回 `undefined`。 | +| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。 +`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 + +可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user' }`。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 ## 能力 -启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的请求: +启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的一次性请求: - `outputSchema`:强制执行结构化最终结果; - `depthLimit`:强制执行 `maxDepth`; - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 -运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。 +可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 ## 持久化描述符 -该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在任何 Task 存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在提供方分发前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 +该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在子 agent 会话存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在物化前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(可继续子 agent 从不捕获它)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 ## 委派深度 @@ -57,23 +61,35 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 -## 所有权与生命周期 +## 一次性所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。 +`provider.start(request): Promise` 是所有权转移边界,也是唯一由 Task 支撑的后台路径。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 -本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +## 可继续子 agent 与 Activation -运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 +可继续子 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 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发——持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入——因此用户可以在不加载历史父级的情况下冷恢复持久化子 agent。 + +受继续执行管理的父级 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),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 + +运行事件受执行委派的父级作用域约束;用户恢复的可继续子 agent 没有执行委派的父级,因此其生命周期会全局到达无作用域的监听器。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task。可继续后台委派会调用 `ctx.subagents.startContinuable()`;只有 `ctx.tasks` 和 `ctx.agents` 可用时,其内部管理器才会存在,而会话持久化按每项继续执行操作解析。收集和取消使用共享 Task 工具。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 ## 模型体验 @@ -85,5 +101,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 已知限制与延期工作 -- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。 -- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 +- **ACP 子 agent 仍为一次性**:ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 +- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。 +- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级或用户无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。 +- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 +- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index fc7ab47339..c717ced0a2 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md -README.md: 44fbd44b035ce283e404c491d9fa143a08b71127 -README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4 +README.md: b62870217e0eaf57c1cd16204c703aada694d4f2 +README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 44fbd44b03..b62870217e 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience @@ -12,7 +12,7 @@ The tool performs no lifecycle routing. It attributes every follow-up as `{ kind #### What the model sees -The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, with delivery-or-continue semantics and the `task_output` collection path described. +The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that the subagent does not reply, and that a failure means the message was not delivered. #### Token effect @@ -26,11 +26,11 @@ Prefix-stable; the schema does not change at runtime. #### What the model sees -`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it started a cold-resume activation. Synchronous routing failures — an ownership conflict, a lost steering race, no live-delivery capability — are errored results whose message states the message was not delivered. An absent activation always reports `started`: lookup runs inside that Task, so an unknown, foreign, or descriptor-less child surfaces as the started Task settling `failed` (read through `task_output`), not as an errored `send_message` result. +`message queued as the next turn for subagent ` on acceptance; the canonical output carries the accepted `messageId`. A failure — an unauthorized or unknown child, a descriptor-less child that cannot be resumed, or admission rejected — is an errored result whose message states the message was not delivered. #### Token effect -One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` (the completion notice is a status line, never the response). +One short acknowledgement per call; the child's response never returns through this tool, so its output enters parent history only if a caller reads the child transcript and relays it. #### KV Cache effect @@ -38,5 +38,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **A delivered message has no independent result** — its effect is reflected in the current Task's eventual result; only a started follow-up owns a fresh Task result. -- **Delivery can lose timing races** — a message racing task settlement, cancellation, or cleanup fails explicitly rather than falling through to cold resume; the model retries after the task settles. +- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work on that turn lands in the durable child Session, read by its subagent id, and is neither delivered back nor collected through this tool. +- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 3fa1d1e543..24a4b7b69a 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -4,7 +4,7 @@ 可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 -本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 +本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它从 `exec.agent` 提供准确的实时父级权限(`{ kind: 'parent', agent }`),并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 ## 模型体验 @@ -12,7 +12,7 @@ #### 模型看到的内容 -已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。 +已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明消息会成为子 agent 的下一个轮次、子 agent 不会回复,以及失败即表示消息未送达。 #### Token 影响 @@ -26,11 +26,11 @@ #### 模型看到的内容 -消息加入运行中的激活时返回 `message delivered to running task `;消息启动一次从持久化存储恢复的激活时返回 `message started task continuing subagent `。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。 +接受时返回 `message queued as the next turn for subagent `;规范输出携带被接受的 `messageId`。失败,包括未授权或未知的子 agent、缺少描述符而无法恢复的子 agent,或准入被拒绝,都会成为出错的结果,其消息说明该消息未送达。 #### Token 影响 -每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。 +每次调用产生一条简短确认消息;子 agent 的响应绝不会通过本工具返回,因此只有当调用方读取子 agent transcript 并转达时,其输出才会进入父级历史。 #### KV Cache 影响 @@ -38,5 +38,5 @@ ## 已知限制与延期工作 -- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。 -- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。 +- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 在该轮次的工作会落入持久化子 agent Session,按其 subagent id 读取,既不会回传,也不会通过本工具收集。 +- **不对当前轮次进行 steering**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。 diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 46daae94ea..e0f4c5e66d 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 9d60363602a9825730984700a7fe987d911e1cac -README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3 +README.md: db6a96e1417eba565ce649393a5937754279be0e +README.zh.md: c4c3175635d287d15ba4cd71c11b87818dcdc3e2 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9d60363602..db6a96e141 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). +With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -21,7 +21,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | -| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. | +| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires the provider's `prepareContinuable` capability and returns a durable child id; it does not require the follow-up tool. | | `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | @@ -37,7 +37,7 @@ Foreground and background calls are exclusive. Children may share the parent's w #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation and returns its subagent id, while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`. #### Token effect @@ -61,15 +61,15 @@ The prompt and result remain in parent history until compaction; child working c Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. -### Background task result +### Background result #### What the model sees -Start returns exactly `started subagent as task ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode the child does not report back; an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its output. #### Token effect -The acknowledgement is retained; final output enters parent history only when collected or injected. +The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool. #### KV Cache effect @@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Background runs expose final output only** — intermediate child steps stay in the child session. +- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. - **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names. - **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 5964c38bd8..c4c3175635 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 -设置 `run_in_background: true` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task `。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时兑现:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript 即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,7 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | -| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方具备 `prepareContinuable` 能力并返回持久化子 agent ID;它不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -37,7 +37,7 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话并返回子 agent id 的后台子 agent,而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。 #### Token 影响 @@ -61,15 +61,15 @@ 仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 -### 后台任务结果 +### 后台结果 #### 模型看到的内容 -在已配置的 continuable 模式下,启动时精确返回 `started subagent as task `;在已配置的 one-shot 模式下,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。 +在配置的可继续模式下,启动时精确返回 `started subagent `;在配置的一次性模式下,则返回 `started background subagent task `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。 #### Token 影响 -确认消息会被保留;最终输出只在收集或注入时进入父级历史。 +确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回。 #### KV Cache 影响 @@ -77,6 +77,6 @@ ## 已知限制与暂缓事项 -- **后台运行只公开最终输出**:子 agent 中间步骤留在子 agent 会话中。 +- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。 - **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。 - **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 From 55f86367adf11a3291e09a53f95117879ef59bb6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:52:44 +0800 Subject: [PATCH 08/66] test(subagent): update in-process specs and pin both durability failure modes Deletes the in-process durability, resume, and steering tests whose premises the seam no longer has, keeping a one-shot analogue for agent-option resolution, and covers both a false and a rejecting final checkpoint in the manager spec. --- .../subagent/tests/continuation.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 8335d665e1..dbc4850b76 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -461,6 +461,23 @@ describe('continuable durability and teardown', () => { }) }) + it('reports DURABILITY_FAILED when the final checkpoint rejects', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const warnings: string[] = [] + ctx.logger.warn = (message: string) => { warnings.push(message) } + // A listener that throws makes flush reject rather than return false. + ctx.on('session/flush', (session) => { + if (session.header.parentSession !== undefined) throw new Error('disk full') + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + // The handle is still disposed and ownership released, so nothing is pinned. + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { + expect(warnings.some(warning => warning.includes('durability checkpoint failed'))).toBe(true) + }) + }) + it('disposes every live Activation forest child-first on manager teardown', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([ From bc504195df6e17aa2fd4816eea04c0bb0cfcb3fd Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:01:13 +0800 Subject: [PATCH 09/66] test(subagent): close continuable coverage and drop unreachable guards Restores the one-shot settleRun coverage in its own file beside the helper, covers fork's seed contribution, the post-transfer rollback, the descriptor model route on cold resume, manager-unload drain, and a failing teardown branch. Removes three redundant checks the surrounding contracts already own: the duplicate-Activation and live-id pre-checks (AgentRegistry.enter is the authoritative collision boundary) and a rollback lifecycle edge that could never publish because the epoch had no start edge. --- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.zh.md | 269 ++++++++++-------- .../subagent-fork/tests/subagent-fork.spec.ts | 29 ++ .../subagent/subagent/src/continuation.ts | 55 +--- packages/subagent/subagent/src/index.ts | 7 +- .../subagent/tests/continuation.spec.ts | 182 +++++++++++- .../subagent/tests/run-settlement.spec.ts | 79 +++++ 7 files changed, 446 insertions(+), 179 deletions(-) create mode 100644 packages/subagent/subagent/tests/run-settlement.spec.ts diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index a535c7ab81..d655798990 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -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: 8f24afec47a970711aae49cae6b3535b9f532e5f -subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c +subagent.md: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900 +subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3 diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 50c5cb887e..541eace7fc 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,24 +4,25 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。 +提供方通过一个静态描述符公布其**启动时**特性,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-seam-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-seam-subagentprovider)。 ```ts type-equiv /** * 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 * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities are optional methods whose presence is the capability — confirmed live steering - * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each - * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to - * `maxDepth`; the other names match. + * degradation" rule). These flags describe the ONE-SHOT + * {@link SubagentProvider.start} path, where the provider composes the child; + * continuable children are composed by the continuation manager itself and are + * gated by {@link SubagentProvider.prepareContinuable} instead. Each flag + * corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` + * to `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -31,16 +32,16 @@ interface SubagentCapabilities { } ``` -## 启动请求 +## 单次启动请求 工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。 ```ts type-equiv /** - * What a caller asks for when starting a subagent. The tool layer builds this - * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider and - * resolves a {@link SubagentProviderStartRequest} for dispatch. + * What a caller asks for when starting a ONE-SHOT subagent. The tool layer + * builds this from the model's `{ description, prompt }` plus its own config; + * the service validates {@link SubagentCapabilities} against the named provider + * before dispatching to {@link SubagentProvider.start}. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -94,31 +95,41 @@ interface SubagentStartRequest { `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 -提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 +提供方接收的正是此请求:单次委派不含由服务解析的继续执行状态,因为可继续子 agent 绝不会到达 `SubagentProvider.start()`。 -```ts type-equiv -/** - * Provider-facing start request after the service resolves optional - * continuation state. Ordinary callers use {@link SubagentStartRequest}; only - * the Task-backed continuation path can attach a stable child identity and - * durable descriptor. - */ -interface SubagentProviderStartRequest extends SubagentStartRequest { - /** - * Continuable-child state resolved by `ctx.subagents` before provider dispatch. - * The provider MUST publish exactly `sessionId` as the child identity - * instead of allocating one internally, and MUST append the snapshotted, - * model-hidden `subagent/descriptor` before the initial prompt is admitted. - * Requires {@link SubagentProvider.resume} (the - * continuation capability); the service rejects the request otherwise. - */ - readonly continuation?: SubagentContinuation | undefined -} +## 可继续子 agent 与激活 + +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、授权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 + +```text +persisted Session + -> optional live Activation + -> one retained AgentHandle + -> Agent inbox as the only turn FIFO + -> zero or more owned child Activations ``` -## 可继续子 agent 与提供方恢复 +`SubagentService.startContinuable()` 会预留稳定的子 agent id,对版本化的 `subagent/descriptor` payload 建立快照,向指定提供方索取其分离的 `ContinuableCreateSpec`,通过私有的 activation-owner 作用域创建子 Agent,建立任何可继续父级的所有权,并提交初始 prompt。当收件箱(inbox)准入产出消息 id 时,它以 `{ childId, messageId }` resolve——无需等待轮次开始,也无需等待消息进入会话日志。在该准入之前的任何失败都会以两个 id 都不返回的方式 reject,并 dispose 任何已创建的 handle,回滚 Activation 与父级所有权。 -**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 +`SubagentService.followup()` 是唯一的继续执行消息操作,其路由仅取决于 Activation 的驻留状态: + +| Activation 状态 | 发送方 | `followup` | +|---|---|---| +| `running` | parent 或 user | 在同一 Activation 中入队 | +| `waiting` | parent 或 user | 唤醒同一 Activation | +| 无 Activation | parent 或 user | 冷恢复一个新的 Activation | + +`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose `AgentHandle` 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些状态,而非维护第二套执行状态机;`activationState()` 报告当前值(无存活 Activation 时为 `undefined`)。 + +Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 `Agent.followup()` FIFO 轮次,因此 parent 与 user 消息共享同一个可观测顺序,且后续消息无法改变已在进行中的轮次。投递成功会返回被接受的 `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 历史父级的情况下冷恢复它。 + +对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 + +每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 + +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -131,76 +142,90 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * Options for following up with one continuable child. + * 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. */ + | { readonly kind: 'user' } +``` + +```ts type-equiv +/** Options for following up with one continuable child. */ interface SubagentFollowupOptions { - /** Durable attribution retained on either live or resumed delivery. */ + /** Durable attribution retained on the delivered message; it grants no authority. */ readonly source: MessageSource - /** Caller cancellation for a live-delivery admission wait. */ + /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal } ``` ```ts type-equiv -/** - * How a continuable follow-up was routed: - * `steered` joined the running activation's existing Task without creating a - * Task of its own; `started` created a fresh Task that cold-resumes the - * durable child with the content. Failure is an exception, never a result — - * undelivered content throws. - */ -type SubagentFollowupResult = - | { readonly route: 'steered'; readonly taskId: TaskId } - | { readonly route: 'started'; readonly taskId: TaskId } -``` - -```ts type-equiv -/** - * The resolved continuable-child identity and durable composition record the - * service attaches before provider dispatch. - */ -interface SubagentContinuation { - /** Service-allocated stable child session id, published verbatim. */ - readonly sessionId: SessionId - /** Snapshotted descriptor persisted in the child log for cold resume. */ - readonly descriptor: SubagentDescriptorData +/** Identities returned once a continuable child accepted its initial prompt. */ +interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The accepted initial prompt's inbox message id. */ + readonly messageId: MessageId } ``` ```ts type-equiv /** - * Provider-facing request for reconstructing a persisted continuable child. - * The continuation manager loads the child log, folds and authorizes its - * descriptor, then privately dispatches this resolved request to - * {@link SubagentProvider.resume}. The provider reconstructs the declared - * composition under the live parent's scope and drives one turn with `prompt`. + * 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. */ -interface SubagentProviderResumeRequest { - /** The persisted child session id to resume. */ +type ActivationState = 'running' | 'waiting' | 'settled' +``` + +提供方只参与准备初始创建 spec,`spawn` 与 `fork` 在此有所不同。其返回的 spec 只携带分离的、提供方专属的创建输入——目前是可选的父级历史种子——不含 Agent、`AgentHandle`、prompt 投递、结果、dispose 或 resume 操作。冷恢复根本不经由提供方分发:管理器折叠通用描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,并提交等待中的轮次。 + +```ts type-equiv +/** + * What the continuation manager asks a provider for while materializing one + * continuable child's FIRST activation. The manager has already reserved the + * durable child identity and owns every later operation, so this request + * carries only what distinguishes a fresh child from one seeded with parent + * history. + */ +interface ContinuableCreateRequest { + /** The reserved durable child session id, for provider diagnostics. */ readonly sessionId: SessionId - /** The follow-up message that starts the resumed activation's turn. */ - readonly prompt: ContentBlock[] - /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ - readonly source: MessageSource - /** - * The live parent agent — the direct parent recorded in the persisted child - * header. In-process backends reconstruct the child under this agent's - * currently loaded scope. - */ + /** The delegating parent agent whose history a seeding provider reads. */ readonly parent: Agent /** - * Activation-owned cancellation signal, created before descriptor lookup. - * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: - * an abort before publication rejects after rollback quiescence, and an - * abort afterward cancels the published child turn. + * Caller cancellation, which owns preparation only until the manager accepts + * the initial prompt into the child's inbox. */ readonly signal: AbortSignal - /** The folded durable descriptor whose composition the provider reconstructs. */ - readonly descriptor: SubagentDescriptorData } ``` -描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 +```ts type-equiv +/** + * A provider's detached contribution to one continuable child's creation. This + * is DATA, never a capability: it carries no Agent, `AgentHandle`, prompt + * delivery, result, disposal, or resume operation, because the continuation + * manager owns the child's whole lifecycle after preparation. + */ +interface ContinuableCreateSpec { + /** + * Completed-turn prefix of the parent's log to seed the child session with, + * or absent for a fresh child. Same durable contract as + * `CreateAgentOptions.seed`: contiguous from seq 0, lossless JSON, balanced. + */ + readonly seed?: readonly SessionEvent[] +} +``` + +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照——提供方名称、已解析的子 agent `agentOptions.provider`/`model`、可选的 `persona`/`toolFilter`——绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。它省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次结果契约,而非持久化组合配置)。继续执行管理器会在任何提供方提供的谱系之后、初始 prompt 获准之前,追加对模型隐藏的 `subagent/descriptor` 事件;`header.seedLength` 仍是 fork 谱系边界,因此描述符查找会读取子 agent 自身的后缀。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 ## 终态结果:`SubagentResult` @@ -249,17 +274,18 @@ interface SubagentStopReasonMap { } ``` - +## 单次 run:`SubagentRun` -## 活跃 run:`SubagentRun` - -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。 +`SubagentRun` 是消费方持有的、指向一个就绪单次子 agent 的句柄——一次可 dispose 的前台委派,只有一个结果,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。run 没有 steering,也没有 resume:可继续对话根本没有 run,因为继续执行管理器直接持有它们的 `AgentHandle`,并通过子 agent 自己的收件箱为每个轮次排序。 ```ts type-equiv /** - * Child handle returned only after readiness. Consumers await {@link result} and must always - * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime - * capability discovery; narrow their presence before calling. + * ONE-SHOT child handle returned only after readiness. Consumers await + * {@link result} and must always {@link dispose} to cancel remaining work and + * reach quiescence. A run is one disposable foreground delegation with one + * result; continuable conversations have no run — the continuation manager + * holds their `AgentHandle` directly and orders every turn through the child's + * own inbox. */ interface SubagentRun { /** @@ -278,10 +304,8 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. For a continuable activation, a completed result - * also means the provider confirmed the activation's final state durable. - * Rejects on an infrastructure fault the seam cannot represent as a stop - * reason, including a failed required durability checkpoint. + * `isError` tool result. Rejects on an infrastructure fault the seam cannot + * represent as a stop reason. */ readonly result: Promise /** @@ -289,25 +313,16 @@ interface SubagentRun { * Idempotent. */ dispose(): Promise - /** - * OPTIONAL (confirmed live-steering capability): submit additional content - * to the active child and fulfill only after a committed request snapshot - * admits it. Rejects when terminal policy, cancellation, disposal, or a lost - * settlement race prevents admission; it never falls through to a queued - * untracked turn or cold resume. A run represents one disposable activation, - * so resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the admitted steering message without changing its - * user role in model history. - */ - steer?(content: ContentBlock[], source: MessageSource): Promise } ``` -本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 +本地单次 run 必须在 `start()` fulfill 之前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切的子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 + + ## 提供方 seam:`SubagentProvider` -每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 +每个提供方都是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力,并拒绝在没有 `prepareContinuable` 的提供方上发起可继续 start。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型措辞,而不暗示继承了工具、服务或权限。 ```ts type-equiv /** @@ -327,33 +342,37 @@ interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Establish a child and return its handle only after publication. The - * service has already validated that every requested start-time capability - * is supported, so an implementation may assume e.g. `request.maxDepth` is - * honorable when present. If setup fails or `request.signal` aborts before - * fulfillment, the provider owns and cleans all partial resources before this - * promise rejects. Ownership transfers to the caller only on fulfillment. + * Establish a ONE-SHOT child and return its handle only after publication. + * The service has already validated that every requested start-time + * capability is supported, so an implementation may assume e.g. + * `request.maxDepth` is honorable when present. If setup fails or + * `request.signal` aborts before fulfillment, the provider owns and cleans + * all partial resources before this promise rejects. Ownership transfers to + * the caller only on fulfillment. */ - start(request: SubagentProviderStartRequest): Promise + start(request: SubagentStartRequest): Promise /** - * OPTIONAL (continuation capability): reconstruct a persisted continuable - * child from its own transcript and declared descriptor, drive one - * follow-up turn, and return a fresh run. Method presence is the capability - * — the service rejects continuable starts and cold-resume dispatch on - * providers without it. Same publication contract as {@link start}: if - * reconstruction fails or `request.signal` aborts before fulfillment, the - * provider rolls its creation transaction back to quiescence before - * rejecting; after fulfillment the same signal cancels the published run. + * OPTIONAL (continuable-creation capability): contribute the detached + * creation inputs that distinguish this provider's continuable children — + * today only whether the child session is seeded with parent history. Method + * presence IS the capability: the service rejects continuable starts on + * providers without it, while a provider that has it may still serve + * ordinary one-shot delegations. + * + * This is the provider's ONLY participation in a continuable child. The + * continuation manager owns identity reservation, composition, Agent + * creation, prompt delivery, cold resume, ownership, and disposal, so a + * provider never sees the child's Agent, handle, turns, or teardown. */ - resume?(request: SubagentProviderResumeRequest): Promise + prepareContinuable?(request: ContinuableCreateRequest): Promise } ``` -提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。 ## 进程内后端:深度与种子 -spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: +spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose;而可继续子 agent 则由继续执行管理器通过其自己的 activation-owner 作用域创建。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: -- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 -- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 +- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 +- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f94ff5dbc6..924ed2df62 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -211,6 +211,35 @@ describe('dsh-subagent-fork', () => { expect(ctx.subagents.list()).toEqual([]) }) + it('contributes the completed-turn prefix as a continuable child\'s seed', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child answer')]) + const provider = ctx.subagents.getProvider('fork')! + const signal = new AbortController().signal + + // Before any completed parent turn there is nothing to inherit, so the + // child starts fresh rather than carrying an empty seed. + const fresh = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-fresh'), + parent, + signal, + }) + expect(fresh.seed).toBeUndefined() + + // Complete one parent turn, then the prefix is captured once at creation. + parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + await parent.whenIdle() + const seeded = await provider.prepareContinuable!({ + sessionId: SessionId('continuable-seeded'), + parent, + signal, + }) + expect(seeded.seed).toBeDefined() + const lastSeeded = seeded.seed!.at(-1) + // The seed ends at a completed turn, so it replays as a valid child log. + expect(lastSeeded?.type).toBe('turn/end') + expect(seeded.seed!.map(event => event.seq)).toEqual(seeded.seed!.map((_event, index) => index)) + }) + it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in fork).toBe(false) expect(fork.name).toBe('subagent-fork') diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 80021ce205..e1a03489d7 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -113,10 +113,10 @@ export interface ActivationObserver { /** * Publish the terminal edge exactly once. An epoch that never became resident * emits nothing, because it has no start edge to pair. - * @param child - the child agent whose final output the edge reports, if any. + * @param child - the child agent whose final output the edge reports. * @param failure - the teardown or durability failure, or `undefined` on success. */ - settle(child: Agent | undefined, failure: unknown): void + settle(child: Agent, failure: unknown): void } /** Hooks the manager needs from the owning service. */ @@ -243,24 +243,6 @@ export class SubagentContinuationManager { }.bind(this), 'subagents.continuations()') } - /** - * Whether this manager still admits new materialization and delivery. Host - * teardown closes admission synchronously through {@link enterDraining}. - * @returns true once draining began. - */ - get isDraining(): boolean { - return this.draining - } - - /** - * Close admission synchronously: reject new creation, cold resume, and - * delivery so a host can drain the live Activation forest without racing new - * work. Idempotent. - */ - enterDraining(): void { - this.draining = true - } - /** * Read one durable child's live residency state. * @param childId - the durable child session id. @@ -384,7 +366,9 @@ export class SubagentContinuationManager { * @throws an aggregate error when any branch failed to release. */ async drain(): Promise { - this.enterDraining() + // Close admission synchronously before the first await, so no new creation, + // cold resume, or delivery can race the snapshot below. + this.draining = true // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -500,18 +484,10 @@ export class SubagentContinuationManager { signal: AbortSignal }): Promise { const { childId, provider, parent } = inputs - if (this.activations.has(childId)) { - throw new SubagentError( - `subagent "${childId}" already has a live activation; the message was not delivered`, - 'ACTIVATION_CONFLICT', - ) - } - if (this.ctx.agents.get(childId) !== undefined) { - throw new SubagentError( - `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, - 'OWNERSHIP_CONFLICT', - ) - } + // No id pre-check here: the child lock serializes each durable child, both + // callers reach this only after confirming no Activation exists, and + // `AgentRegistry.enter()` is the authoritative collision boundary for an id + // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } const observer = this.host.observeActivation(provider, childId, parent) @@ -535,7 +511,7 @@ export class SubagentContinuationManager { } catch (error: unknown) { // Agent creation provides rollback before handle transfer, so nothing // outlives this rejection; report the epoch that never became resident. - observer.settle(undefined, error) + // No start edge was published, so this epoch has no lifecycle to close. throw error } @@ -558,16 +534,11 @@ export class SubagentContinuationManager { } catch (error: unknown) { // Roll the transfer back completely: the Activation leaves the map, the // parent's ownership membership is released, and the created handle is - // disposed before this rejection surfaces. + // disposed before this rejection surfaces. No lifecycle edge is published, + // because `observer.start()` below has not run for this epoch. this.activations.delete(childId) this.releaseOwnership(childId) - activation.disposal = (async () => { - try { - await handle.dispose() - } finally { - observer.settle(handle.agent, error) - } - })() + activation.disposal = handle.dispose() await activation.disposal.catch(() => undefined) throw error } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 3be59f2266..5f5f0d6fab 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -369,7 +369,7 @@ export class SubagentService extends Service { started = true this.emitLifecycle('subagent/start', identity, parent) }, - settle: (child: Agent | undefined, failure: unknown): void => { + settle: (child: Agent, failure: unknown): void => { // A failure before residency has no start edge to pair, and inventing // one would report a lifecycle the child never had. if (settled || !started) return @@ -462,9 +462,10 @@ export class SubagentService extends Service { /** * The child's last assistant message content, for one Activation's terminal * lifecycle edge. Absent when no assistant message reached the log. + * @param child - the settling child agent whose log is read. + * @returns its final assistant content, or `undefined` when it produced none. */ -function lastAssistantOutput(child: Agent | undefined): ContentBlock[] | undefined { - if (child === undefined) return undefined +function lastAssistantOutput(child: Agent): ContentBlock[] | undefined { const message = child.session.events.findLast( (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', ) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dbc4850b76..2728415fde 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -630,13 +630,181 @@ describe('continuable public surface', () => { }) describe('continuable errors', () => { - it('rejects a second live Activation for the same durable child', async () => { - const { ctx, parent } = await setup([textResponse('unused')]) - // Occupy the id with an unmanaged live Agent. - const squatter = ctx.agentLoop.create(SessionId('squatted'), { provider: 'mock', model: 'mock' }) - await ctx.sessions.flush(squatter.session) - await expect(followup(ctx, { kind: 'user' }, SessionId('squatted'), message('hello'))) + it('rejects a duplicate Activation at the agent registry collision boundary', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // Drop the Activation without disposing the Agent, leaving the id live but + // unmanaged. Materialization must not adopt it. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map } + }).continuations + manager.activations.delete(started.childId) + + await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) .rejects.toThrow(SubagentError) - void parent + expect(ctx.agents.get(started.childId)).toBe(child) + hold.resolve() + }) + + it('rejects parent authority whose agent 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(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + // 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'))) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + void child + }) + + it('rejects establishing a child under a parent whose disposal already began', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + + // Begin the parent Activation's teardown, then try to give it a child. + const drained = ctx.subagents.drainContinuable() + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'DRAINING' }) + hold.resolve() + await drained + }) + + it('reports a failing branch after every branch settles, without pinning the rest', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child done') }, + { chunks: textResponse('grandchild'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) + // Make the grandchild's own handle disposal reject: scope teardown failure + // propagates, unlike a contained `agent/disposed` listener throw. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const branch = manager.activations.get(grandchild.childId)! + const realDispose = branch.handle.dispose.bind(branch.handle) + branch.handle.dispose = async () => { + await realDispose() + throw new Error('grandchild reap failed') + } + + const drained = ctx.subagents.drainContinuable() + hold.resolve() + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + // The other branch still released, and durable sessions survive. + expect(ctx.agents.get(started.childId)).toBeUndefined() + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + }) + + it('rolls the transfer back when ownership registration fails after handle transfer', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('parent child'), gate: hold.promise }, + { chunks: textResponse('unused') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const outer = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(outer.childId) + expect(found).toBeDefined() + return found! + }) + // Begin the would-be parent's disposal, then race a grandchild into it. The + // handle transfers before ownership registration rejects, so the rollback + // must leave no Activation and no live Agent behind. + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map | undefined }> } + }).continuations + const before = new Set(ctx.agents.list().map(agent => agent.id)) + manager.activations.get(outer.childId)!.disposal = Promise.resolve() + + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' }) + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) + }) + hold.resolve() + }) + + it('reapplies the descriptor model route on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')]) + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('routed work'), + parent, + agentOptions: { provider: 'mock', model: 'child-model' }, + }, + }) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) + .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) + + // The resumed Activation runs on the declared route, not the parent's. + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await vi.waitFor(() => { + expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') + }) + await waitNoActivation(ctx, started.childId) + }) + + it('drains without continuation services as a no-op', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SubagentService) + // No `ctx.agents`, so no manager was ever bound and nothing was materialized. + await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + }) + + it('unloading the manager drains its live activations', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + const serviceFiber = await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() }) + + // Manager unload uses the same drain, so no child outlives its runtime. + const disposal = serviceFiber.dispose() + hold.resolve() + await disposal + expect(ctx.agents.get(started.childId)).toBeUndefined() }) }) diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts new file mode 100644 index 0000000000..576eaa17b5 --- /dev/null +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { settleRun } from '../src/index.ts' + +describe('outcome mapping helpers', () => { + it.each([ + ['completed', { status: 'completed', output: 'partial' }], + ['aborted', { status: 'killed' }], + ['error', { status: 'failed', detail: 'error' }], + ['max-tokens', { status: 'failed', detail: 'max-tokens' }], + ['refusal', { status: 'failed', detail: 'refusal' }], + ['paused', { status: 'failed', detail: 'paused' }], + ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { + const output = [{ type: 'text' as const, text: 'partial' }] + await expect(settleRun({ + id: SessionId('child'), + localAgent: undefined, + result: Promise.resolve({ output, stopReason: stopReason as never }), + dispose: () => Promise.resolve(), + })).resolves.toEqual(expected) + }) + + it('settleRun disposes the run before reporting, on both result paths', async () => { + const order: string[] = [] + const completed = await settleRun({ + id: SessionId('child-1'), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), + dispose() { order.push('dispose'); return Promise.resolve() }, + }) + order.push('reported') + expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(order).toEqual(['dispose', 'reported']) + + // An infrastructure rejection still disposes and reports failed. + let disposed = false + const failed = await settleRun({ + id: SessionId('child-2'), + localAgent: undefined, + result: Promise.reject(new Error('transport gone')), + dispose() { disposed = true; return Promise.resolve() }, + }) + expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) + expect(disposed).toBe(true) + + const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' + const durabilityFailed = await settleRun({ + id: SessionId('child-3'), + localAgent: undefined, + result: Promise.reject(new HarnessError( + durabilityMessage, + 'DURABILITY_FAILED', + { cause: new Error('disk full') }, + )), + dispose: () => Promise.resolve(), + }) + expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) + + const disposeFailed = await settleRun({ + id: SessionId('child-4'), + localAgent: undefined, + result: Promise.resolve({ output: [], stopReason: 'completed' }), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) + + const bothFailed = await settleRun({ + id: SessionId('child-5'), + localAgent: undefined, + result: Promise.reject(new Error('result failed')), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(bothFailed).toEqual({ + status: 'failed', + detail: 'Error: result failed; dispose failed: Error: reap failed', + }) + }) +}) From 3911b7117ee9c55d1e0f5b7f747596b5ca143b91 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:09:24 +0800 Subject: [PATCH 10/66] test(subagent): reach full continuable coverage and simplify unreachable paths Adds coverage for the fork-seeded descriptor turn numbering, omitted and declared descriptor composition fields, a routeless cold resume, and the drain no-op. Splits materialize's create-versus-resume inputs so the impossible create-without-meta case disappears, drops the observer's unreachable pre-residency guard, and annotates the three remaining paths that only a non-deterministic send-versus-dispose race can reach. --- .../subagent/subagent/src/continuation.ts | 32 ++-- packages/subagent/subagent/src/index.ts | 10 +- .../subagent/tests/continuation.spec.ts | 164 ++++++++++++++---- .../subagent/subagent/tests/service.spec.ts | 6 + 4 files changed, 163 insertions(+), 49 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index e1a03489d7..3ab4d342ef 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -111,8 +111,10 @@ export interface ActivationObserver { /** Publish the start edge once the epoch is resident. */ start(): void /** - * Publish the terminal edge exactly once. An epoch that never became resident - * emits nothing, because it has no start edge to pair. + * Publish the terminal edge exactly once, pairing this epoch's {@link start}. + * Called only for a resident epoch: a failure before residency publishes no + * edge at all, because inventing one would report a lifecycle the child never + * had. * @param child - the child agent whose final output the edge reports. * @param failure - the teardown or durability failure, or `undefined` on success. */ @@ -303,8 +305,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - seed, - meta: childSessionMeta(parent, childDepth, lineageSeedLength), + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -344,16 +345,22 @@ export class SubagentContinuationManager { if (activation === undefined) return this.coldResume(authority, 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 + * delivery to observe the transaction inside the same critical section that opened it, + * which no test can schedule deterministically. The behavior is covered end-to-end by + * "cold-resumes a delivery that lost the race with final disposal". */ if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } await this.authorizeLive(authority, activation) return this.submit(activation, content, options.source, authority) }) + /* 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. */ if (live !== undefined) return live - // The racing disposal completed; retry admission, which now cold-resumes. this.assertAdmitting() options.signal.throwIfAborted() + /* v8 ignore stop */ } } @@ -455,7 +462,6 @@ export class SubagentContinuationManager { childId, provider: descriptor.provider, parent: authority.kind === 'parent' ? authority.agent : undefined, - resume: true, agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, @@ -476,9 +482,8 @@ export class SubagentContinuationManager { childId: SessionId provider: string parent: Agent | undefined - resume?: boolean - seed?: readonly SessionEvent[] - meta?: NonNullable + /** Creation inputs; absent for a cold resume, which loads the persisted session. */ + create?: { seed: readonly SessionEvent[]; meta: NonNullable } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal @@ -493,7 +498,8 @@ export class SubagentContinuationManager { const observer = this.host.observeActivation(provider, childId, parent) let handle: AgentHandle try { - handle = inputs.resume === true + const { create } = inputs + handle = create === undefined ? await this.ownerCtx.agents.resume({ resumeSessionId: childId, agentOptions: inputs.agentOptions, @@ -502,8 +508,8 @@ export class SubagentContinuationManager { }) : await this.ownerCtx.agents.create({ sessionId: childId, - ...inputs.meta !== undefined ? { meta: inputs.meta } : {}, - ...inputs.seed !== undefined ? { seed: inputs.seed } : {}, + meta: create.meta, + seed: create.seed, agentOptions: inputs.agentOptions, signal: inputs.signal, setup, @@ -539,6 +545,8 @@ export class SubagentContinuationManager { this.activations.delete(childId) this.releaseOwnership(childId) activation.disposal = handle.dispose() + /* v8 ignore next -- the created handle disposes cleanly on every rollback this + * transaction can reach; the catch only keeps a disposal fault from masking `error`. */ await activation.disposal.catch(() => undefined) throw error } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5f5f0d6fab..0789113e7e 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -362,17 +362,17 @@ export class SubagentService extends Service { parent: Agent | undefined, ): ActivationObserver { const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: true } - let started = false let settled = false return { start: (): void => { - started = true this.emitLifecycle('subagent/start', identity, parent) }, settle: (child: Agent, failure: unknown): void => { - // A failure before residency has no start edge to pair, and inventing - // one would report a lifecycle the child never had. - if (settled || !started) return + // 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 ? lastAssistantOutput(child) : undefined this.emitLifecycle('subagent/end', { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 2728415fde..612c6763e8 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -13,6 +13,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, @@ -25,7 +26,7 @@ type Script = ConstructorParameters[0] /** One scripted response that may wait on a caller-released gate before streaming. */ interface GatedEntry { chunks: StreamChunk[] - gate?: Promise + gate?: Promise } /** Adapter whose entries can hold a model call open until the test releases it. */ @@ -221,6 +222,104 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) + it('omits undeclared composition fields from the descriptor', async () => { + const { ctx } = await setup([]) + // A routeless parent declares no provider/model, and this start declares no + // persona or tool filter, so the descriptor records only what exists. + const routeless = ctx.agentLoop.create(SessionId('routeless'), {}) + const started = await ctx.subagents.startContinuable(startSpec(routeless)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const descriptor = child.session.events.find(event => event.type === 'subagent/descriptor') + + expect(descriptor?.data).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + }) + await ctx.subagents.drainContinuable() + }) + + it('records a declared tool filter in the descriptor', async () => { + const { ctx } = await setup([]) + // Register one global tool so the filter names something real. + ctx.tools.register(defineTool({ + name: 'noop', + description: 'does nothing', + parameters: {}, + output: { + schema: { type: 'object', additionalProperties: false, properties: {} }, + render: () => [{ type: 'text', text: 'noop' }], + }, + execute: () => Promise.resolve({}), + })) + const routeless = ctx.agentLoop.create(SessionId('routeless-filtered'), {}) + const started = await ctx.subagents.startContinuable({ + ...startSpec(routeless), + request: { prompt: message('filtered work'), parent: routeless, toolFilter: { deny: ['noop'] } }, + }) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + + expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data) + .toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + toolFilter: { deny: ['noop'] }, + }) + await ctx.subagents.drainContinuable() + }) + + it('cold-resumes without inventing a model route the descriptor never declared', async () => { + const { ctx, root } = await setup([textResponse('first')]) + const routeless = ctx.agentLoop.create(SessionId('routeless-resume'), {}) + const started = await ctx.subagents.startContinuable(startSpec(routeless)) + await waitNoActivation(ctx, started.childId) + + 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' }) + await followup(fresh, { kind: 'user' }, started.childId, message('resume routeless')) + + const resumed = await vi.waitFor(() => { + const found = fresh.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + expect(resumed.options.provider).toBeUndefined() + expect(resumed.options.model).toBeUndefined() + await fresh.subagents.drainContinuable() + }) + + it('numbers the descriptor turn after an inherited fork prefix', async () => { + const { ctx, parent } = await setup([ + textResponse('parent turn'), + textResponse('forked child'), + ]) + // Complete one parent turn so fork has a prefix to contribute. + parent.followup({ content: message('parent work'), source: { kind: 'user' } }) + await parent.whenIdle() + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorTurn = loaded.events.find(event => event.type === 'turn/start' + && event.data.trigger.kind === 'subagent-descriptor') + // The seeded descriptor turn continues the inherited numbering rather than + // restarting at 1, so the replayed child log stays balanced. + expect(descriptorTurn?.type === 'turn/start' && descriptorTurn.data.turn).toBe(2) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + }) + it('records the declared persona in the descriptor and reapplies it on cold resume', async () => { const { ctx, parent } = await setup([textResponse('scoped'), textResponse('resumed')]) const started = await ctx.subagents.startContinuable({ @@ -247,7 +346,7 @@ describe('SubagentService.startContinuable', () => { describe('SubagentService.followup residency routing', () => { it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => { - const releaseFirst = Promise.withResolvers() + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('first'), gate: releaseFirst.promise }, { chunks: textResponse('second') }, @@ -266,7 +365,7 @@ describe('SubagentService.followup residency routing', () => { // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) - releaseFirst.resolve() + 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']) @@ -288,7 +387,7 @@ describe('SubagentService.followup residency routing', () => { }) it('wakes a waiting Activation instead of cold-resuming it', async () => { - const releaseGrandchild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ // The child delegates, then finishes its own turn while the grandchild runs. { chunks: textResponse('child done') }, @@ -315,7 +414,7 @@ describe('SubagentService.followup residency routing', () => { // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) - releaseGrandchild.resolve() + releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) @@ -380,7 +479,7 @@ describe('SubagentService.followup residency routing', () => { .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) - it('cold-resumes after losing a race with final disposal', async () => { + it('cold-resumes a delivery that lost the race with final disposal', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after the race')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) const child = await vi.waitFor(() => { @@ -388,10 +487,11 @@ describe('SubagentService.followup residency routing', () => { expect(found).toBeDefined() return found! }) - // Send exactly while the Activation is settling: one side wins the cutoff, - // and a delivery that loses waits for release and cold-resumes. - await child.whenIdle() - const delivery = followup(ctx, { kind: 'user' }, started.childId, message('raced')) + // Deliver in the same tick the settlement watcher opens its transaction: + // 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, { kind: 'user' }, started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -402,7 +502,7 @@ describe('SubagentService.followup residency routing', () => { describe('continuable child ownership', () => { it('keeps a parent Activation waiting until its child completes disposal', async () => { - const releaseGrandchild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, @@ -423,7 +523,7 @@ describe('continuable child ownership', () => { expect(ctx.agents.get(started.childId)).toBe(child) expect(ctx.agents.get(grandchild.childId)).toBeDefined() - releaseGrandchild.resolve() + releaseGrandchild.resolve(undefined) await waitNoActivation(ctx, grandchild.childId) await waitNoActivation(ctx, started.childId) }) @@ -440,7 +540,7 @@ describe('continuable child ownership', () => { describe('continuable durability and teardown', () => { it('reports DURABILITY_FAILED without leaking a waiting Activation', async () => { - const releaseResponse = Promise.withResolvers() + const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, ]) @@ -452,7 +552,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) // Remove every durability listener, so the final checkpoint cannot confirm. await disposePersistence!() - releaseResponse.resolve() + releaseResponse.resolve(undefined) // The handle is still disposed and ownership released, so nothing is pinned. await waitNoActivation(ctx, started.childId) @@ -479,7 +579,7 @@ describe('continuable durability and teardown', () => { }) it('disposes every live Activation forest child-first on manager teardown', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: hold.promise }, @@ -498,7 +598,7 @@ describe('continuable durability and teardown', () => { ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) const drained = ctx.subagents.drainContinuable() // Let the held model call observe its cancellation so quiescence can settle. - hold.resolve() + hold.resolve(undefined) await drained // Child-first: the grandchild's disposal precedes its parent's. @@ -524,7 +624,7 @@ describe('continuable durability and teardown', () => { }) it('has no automatic replay for an accepted but unlogged message', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -533,7 +633,7 @@ describe('continuable durability and teardown', () => { await followup(ctx, { kind: 'user' }, started.childId, message('never logged')) const drained = ctx.subagents.drainContinuable() - hold.resolve() + hold.resolve(undefined) await drained await waitNoActivation(ctx, started.childId) @@ -548,8 +648,8 @@ describe('continuable lifecycle observation', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const starts: SubagentRunInfo[] = [] const ends: SubagentRunEndInfo[] = [] - ctx.on('subagent/start', info => { starts.push(info) }) - ctx.on('subagent/end', info => { ends.push(info) }) + ctx.on('subagent/start', (info) => { starts.push(info) }) + ctx.on('subagent/end', (info) => { ends.push(info) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) @@ -608,7 +708,7 @@ describe('continuable public surface', () => { }) it('does not cancel an accepted turn when the caller signal aborts afterwards', async () => { - const releaseFirst = Promise.withResolvers() + const releaseFirst = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('first'), gate: releaseFirst.promise }, { chunks: textResponse('second') }, @@ -622,7 +722,7 @@ describe('continuable public surface', () => { // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') - releaseFirst.resolve() + releaseFirst.resolve(undefined) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(hasUserText(loaded.events, 'survives')).toBe(true) @@ -631,7 +731,7 @@ describe('continuable public surface', () => { describe('continuable errors', () => { it('rejects a duplicate Activation at the agent registry collision boundary', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -650,7 +750,7 @@ describe('continuable errors', () => { await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) - hold.resolve() + hold.resolve(undefined) }) it('rejects parent authority whose agent is no longer the live registry entry', async () => { @@ -670,7 +770,7 @@ describe('continuable errors', () => { }) it('rejects establishing a child under a parent whose disposal already began', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -684,12 +784,12 @@ describe('continuable errors', () => { const drained = ctx.subagents.drainContinuable() await expect(ctx.subagents.startContinuable(startSpec(child))) .rejects.toMatchObject({ code: 'DRAINING' }) - hold.resolve() + hold.resolve(undefined) await drained }) it('reports a failing branch after every branch settles, without pinning the rest', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: hold.promise }, @@ -716,7 +816,7 @@ describe('continuable errors', () => { } const drained = ctx.subagents.drainContinuable() - hold.resolve() + hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) // The other branch still released, and durable sessions survive. expect(ctx.agents.get(started.childId)).toBeUndefined() @@ -725,7 +825,7 @@ describe('continuable errors', () => { }) it('rolls the transfer back when ownership registration fails after handle transfer', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([ { chunks: textResponse('parent child'), gate: hold.promise }, { chunks: textResponse('unused') }, @@ -751,7 +851,7 @@ describe('continuable errors', () => { await vi.waitFor(() => { expect(ctx.agents.list().map(agent => agent.id).filter(id => !before.has(id))).toEqual([]) }) - hold.resolve() + hold.resolve(undefined) }) it('reapplies the descriptor model route on cold resume', async () => { @@ -786,7 +886,7 @@ describe('continuable errors', () => { }) it('unloading the manager drains its live activations', async () => { - const hold = Promise.withResolvers() + const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -803,7 +903,7 @@ describe('continuable errors', () => { // Manager unload uses the same drain, so no child outlives its runtime. const disposal = serviceFiber.dispose() - hold.resolve() + hold.resolve(undefined) await disposal expect(ctx.agents.get(started.childId)).toBeUndefined() }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8b68e9554f..2260aaf119 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,6 +119,12 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) + it('drains continuable activations as a no-op when no manager was bound', async () => { + const { subagents } = await service() + // Without `ctx.agents` no manager exists, so nothing was ever materialized. + await expect(subagents.drainContinuable()).resolves.toBeUndefined() + }) + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() await expect(subagents.startContinuable({ From c8fbc111db8f731de4a509f684247dbc971106a4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 14:41:21 +0800 Subject: [PATCH 11/66] fix(acp): drain continuable subagents before disposing top-level agents A continuable Activation outlives the turn that started it and owns descendant teardown, so the bridge must drain that forest child-first before releasing the top-level agents whose runtime the descendants depend on. Also rewrites the authored continuable snapshot transcript for the Task-free tool surface; the scenario's keyless replay is still under diagnosis. --- .../fixtures/subagent-durability-failure.ts | 45 +++++-- .../subagent-continuable/session.1.jsonl | 57 ++++++--- .../subagent-continuable/session.jsonl | 115 +++++++++--------- packages/acp/acp/src/index.ts | 22 +++- 4 files changed, 154 insertions(+), 85 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 7829b3812b..1f08074ca5 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,41 +1,72 @@ import type { Context } from 'cordis' +import { appendFileSync } from 'node:fs' export const name = 'subagent-durability-failure' -export const inject = ['sessionPersistence'] +export const inject = ['sessionPersistence', 'subagents'] const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { + const log = (...a: unknown[]): void => { + try { appendFileSync('/tmp/probe.log', '[PROBE] ' + a.map(String).join(' ') + '\n') } catch { /* ignore */ } + } const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) - // The unavailable-child lookup is real asynchronous I/O. Fence it behind both - // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise + log('load', id) + if (id === UNKNOWN_CHILD_ID) { log('gating unknown-id load'); await followupsAccepted.promise; log('unknown-id load released') } return load.call(persistence, id) } + + // Patch followup to log routing. + const subagents = ctx.subagents as unknown as { followup: (...a: unknown[]) => Promise } + const origFollowup = subagents.followup.bind(subagents) + subagents.followup = async (...args: unknown[]) => { + log('followup childId=', args[1]) + return origFollowup(...args) + } + ctx.effect(() => () => { persistence.load = load followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') - // Both authored follow-ups reach the child inbox before the unknown-id lookup - // runs, so the queued FIFO order is what the transcript records. let accepted = 0 ctx.on('agent/inbox/enqueue', (agent) => { if (agent.session.header.parentSession === undefined) return accepted += 1 + log('child enqueue #', accepted, 'child=', agent.session.header.id) if (accepted >= 3) followupsAccepted.resolve(undefined) }) + ctx.on('subagent/start', (info: unknown) => { + log('subagent/start id=', (info as { id?: unknown }).id) + }) + + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined) return + if (event.type === 'turn/start') log('child turn/start turn=', event.data.turn, 'child=', session.header.id) + if (event.type === 'user/message') { + const c = event.data.content?.[0] + log('child user/message text=', c && c.type === 'text' ? c.text : '?', 'child=', session.header.id) + } + }) + + const flushes = new WeakMap() const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return + const count = (flushes.get(session) ?? 0) + 1 + flushes.set(session, count) + log('child flush #', count, 'child=', session.header.id) if (session.events.at(-1)?.type !== 'turn/end') return - if (flushedTurnEnds.has(session)) throw new Error('snapshot disk full') + if (flushedTurnEnds.has(session)) { + log('THROW snapshot disk full') + throw new Error('snapshot disk full') + } flushedTurnEnds.add(session) }) } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 15e024fa07..67451e499b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,17 +1,40 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"subagent/descriptor","seq":0,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} -{"type":"turn/start","seq":1,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":2,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} -{"type":"session/title","seq":3,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[2],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":4,"time":1785517567401,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"57bfffb1-f18b-4e29-aaca-26ecaea51574"},"surfaceOp":"append"} -{"type":"step/start","seq":5,"time":1785517567401,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1785517567401,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785517567401,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1784795691405,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1785517567410,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"156cd267-c1e6-4030-b317-dc2936120f4a"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1785517567410,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":15,"time":1785517567411,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"/tmp/subagent-continuable","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} +{"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} +{"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}},"seq":3,"time":1789000000004} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":4,"time":1789000000005} +{"type":"step/start","data":{"turn":2,"step":1},"seq":5,"time":1789000000006} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":6,"time":1789000000007} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":7,"time":1789000000008} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}},"seq":8,"time":1789000000009} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},"seq":9,"time":1789000000010} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":10,"time":1789000000011} +{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":11,"time":1789000000012} +{"type":"assistant/message","data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":12,"time":1789000000013} +{"type":"step/end","data":{"turn":2,"step":1},"seq":13,"time":1789000000014} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":14,"time":1789000000015} +{"type":"turn/start","data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":15,"time":1789000000016} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":16,"time":1789000000017} +{"type":"step/start","data":{"turn":3,"step":1},"seq":17,"time":1789000000018} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":18,"time":1789000000019} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":19,"time":1789000000020} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}},"seq":20,"time":1789000000021} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},"seq":21,"time":1789000000022} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":22,"time":1789000000023} +{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":23,"time":1789000000024} +{"type":"assistant/message","data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":24,"time":1789000000025} +{"type":"step/end","data":{"turn":3,"step":1},"seq":25,"time":1789000000026} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":26,"time":1789000000027} +{"type":"turn/start","data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":27,"time":1789000000028} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":28,"time":1789000000029} +{"type":"step/start","data":{"turn":4,"step":1},"seq":29,"time":1789000000030} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":30,"time":1789000000031} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":31,"time":1789000000032} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"text-delta","index":0,"text":"THIRD_OK"}},"seq":32,"time":1789000000033} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"THIRD_OK"}}},"seq":33,"time":1789000000034} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":34,"time":1789000000035} +{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":35,"time":1789000000036} +{"type":"assistant/message","data":{"turn":4,"step":1,"content":[{"type":"text","text":"THIRD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":36,"time":1789000000037} +{"type":"step/end","data":{"turn":4,"step":1},"seq":37,"time":1789000000038} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}},"seq":38,"time":1789000000039} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index e34205b0f2..1b28826e21 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,57 +1,58 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000001,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"42a76bb1-818e-427e-8037-76b33c3a5c1f"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1789000000001,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785517567360,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"6dee8203-be1c-4287-86f3-db1ea0197c19"},"surfaceOp":"append"} -{"type":"step/start","seq":4,"time":1785517567360,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785517567361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":6,"time":1785517567361,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":7,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":10,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":11,"time":1785517567370,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":12,"time":1785517567370,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"631fd641-46e9-4e62-965a-2fd7a87e2720"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} -{"type":"tool/call","seq":13,"time":1785517567370,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":14,"time":1785517567380,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333 as task subagent-1"}],"isError":false}],"role":"user","id":"28d3f6cb-8934-4dcc-9cf2-7db87b0df06a"}},"sourceEventSeqs":[13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785517567380,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":16,"time":1785517567387,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1789000000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":18,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_1","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}} -{"type":"assistant/chunk","seq":19,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}}}} -{"type":"assistant/chunk","seq":20,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":21,"time":1785517567391,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":22,"time":1785517567392,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fcea712-0e14-4f2d-909c-f7de70018053"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"tool/call","seq":23,"time":1785517567392,"data":{"turn":1,"step":2,"callId":"call_collect_1","name":"task_output","arguments":"{\"task_id\": \"subagent-1\", \"wait\": true}"}} -{"type":"tool/result","seq":24,"time":1785517567419,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_collect_1"},"content":[{"type":"tool-result","toolCallId":"call_collect_1","content":[{"type":"text","text":"(no new output)\n[status: failed, subagent \"33333333-3333-4333-8333-333333333333\" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: snapshot disk full]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785517567419,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":26,"time":1785517567425,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_follow_up","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} -{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} -{"type":"assistant/chunk","seq":30,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":31,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":32,"time":1785517567430,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e78130e-5ae7-4ec9-ad34-9e2400a23ef0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} -{"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} -{"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}} -{"type":"assistant/chunk","seq":40,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}}} -{"type":"assistant/chunk","seq":41,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}} -{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":49,"time":1789000000048,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":50,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":51,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":52,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":53,"time":1785517567471,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fa9e88d9-d89c-4df7-85d5-0e4fd795ae69"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"step/end","seq":54,"time":1785517567472,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":55,"time":1785517567472,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1789000000004} +{"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":5,"time":1789000000006} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}},"seq":6,"time":1789000000007} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}},"seq":7,"time":1789000000008} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":8,"time":1789000000009} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":9,"time":1789000000010} +{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":10,"time":1789000000011} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"},"seq":11,"time":1789000000012} +{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}]},"seq":12,"time":1789000000013} +{"type":"step/end","data":{"turn":1,"step":1},"seq":13,"time":1789000000014} +{"type":"step/start","data":{"turn":1,"step":2},"seq":14,"time":1789000000015} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":15,"time":1789000000016} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":16,"time":1789000000017} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}},"seq":17,"time":1789000000018} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}},"seq":18,"time":1789000000019} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":19,"time":1789000000020} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":20,"time":1789000000021} +{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":21,"time":1789000000022} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"},"seq":22,"time":1789000000023} +{"type":"tool/result","data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":23,"time":1789000000024} +{"type":"step/end","data":{"turn":1,"step":2},"seq":24,"time":1789000000025} +{"type":"step/start","data":{"turn":1,"step":3},"seq":25,"time":1789000000026} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":26,"time":1789000000027} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":27,"time":1789000000028} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}},"seq":28,"time":1789000000029} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}},"seq":29,"time":1789000000030} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":30,"time":1789000000031} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":31,"time":1789000000032} +{"type":"assistant/message","data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":32,"time":1789000000033} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"},"seq":33,"time":1789000000034} +{"type":"tool/result","data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":34,"time":1789000000035} +{"type":"step/end","data":{"turn":1,"step":3},"seq":35,"time":1789000000036} +{"type":"step/start","data":{"turn":1,"step":4},"seq":36,"time":1789000000037} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":37,"time":1789000000038} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":38,"time":1789000000039} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}},"seq":39,"time":1789000000040} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}},"seq":40,"time":1789000000041} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":41,"time":1789000000042} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":42,"time":1789000000043} +{"type":"assistant/message","data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":43,"time":1789000000044} +{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"},"seq":44,"time":1789000000045} +{"type":"tool/result","data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true},"seq":45,"time":1789000000046} +{"type":"step/end","data":{"turn":1,"step":4},"seq":46,"time":1789000000047} +{"type":"step/start","data":{"turn":1,"step":5},"seq":47,"time":1789000000048} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":48,"time":1789000000049} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":49,"time":1789000000050} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}},"seq":50,"time":1789000000051} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},"seq":51,"time":1789000000052} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":52,"time":1789000000053} +{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":53,"time":1789000000054} +{"type":"assistant/message","data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":54,"time":1789000000055} +{"type":"step/end","data":{"turn":1,"step":5},"seq":55,"time":1789000000056} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":56,"time":1789000000057} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index f3dd59679a..d76a184f5e 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -326,10 +326,24 @@ export function apply(ctx: Context, config: AcpConfig): void { closed = true const records = [...sessions.values()] sessions.clear() - quiescing = Promise.all(records.map(async (record) => { - settlePrompt(record, 'cancelled') - await record.dispose() - })).then(() => {}) + quiescing = (async () => { + // Continuable subagents outlive the turn that started them, and their + // Activations own descendant teardown. Drain that forest child-first + // BEFORE disposing the top-level agents, so no descendant is left holding + // a runtime its owner already released. + const subagents = ctx.get('subagents') + if (subagents !== undefined) { + try { + await subagents.drainContinuable() + } catch (error: unknown) { + logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) + } + } + await Promise.all(records.map(async (record) => { + settlePrompt(record, 'cancelled') + await record.dispose() + })) + })() return quiescing } From 4435616a04c397a786aa8e177cebea098e6c2914 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:07:19 +0800 Subject: [PATCH 12/66] test(acp-agent): rewrite the continuable snapshot for the Task-free surface The authored transcript drove task_output, which no longer exists for a continuable child and is not registered in this config, so the scenario hung. It now demonstrates the RFC criteria directly: a delegation returning only the durable subagent id, two send_message follow-ups queueing as later FIFO turns on one inbox, an unknown id failing without delivery, and child-first disposal despite a failed final durability checkpoint. The snapshot-only overlay remaps the transcript's placeholder child id onto the randomly minted live child, since the scripted model cannot know that id. Also drops probe logging accidentally committed in cf0138258. --- .../fixtures/subagent-durability-failure.ts | 87 ++++++++------- .../subagent-continuable/session.1.jsonl | 63 +++++------ .../subagent-continuable/session.jsonl | 104 +++++++++--------- 3 files changed, 126 insertions(+), 128 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 1f08074ca5..8352dac005 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,72 +1,83 @@ import type { Context } from 'cordis' -import { appendFileSync } from 'node:fs' +import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-durability-failure' export const inject = ['sessionPersistence', 'subagents'] +/** + * The authored parent transcript names the background child by a stable + * placeholder id, but the live continuable child is minted with a fresh random + * session id at run time. This snapshot-only overlay bridges that gap and forces + * a deterministic ordering plus a failing final child durability checkpoint: + * + * - `PLACEHOLDER_CHILD_ID` in a scripted `send_message` is remapped to the real + * child so both follow-ups queue onto the same live inbox in FIFO order. + * - The unknown-id `send_message` (`UNKNOWN_CHILD_ID`) resolves through a + * persistence load fenced behind both accepted follow-ups, so the transcript + * records the same order on every runner. + * - The child's final continuation turn fails its durability checkpoint with a + * fixed message, so the scenario proves child-first disposal survives a failed + * last flush. + */ +const PLACEHOLDER_CHILD_ID = '33333333-3333-4333-8333-333333333333' const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' +/** The child continuation turn whose durability checkpoint is forced to fail. */ +const FAILED_CHECKPOINT_TURN = 4 /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { - const log = (...a: unknown[]): void => { - try { appendFileSync('/tmp/probe.log', '[PROBE] ' + a.map(String).join(' ') + '\n') } catch { /* ignore */ } - } const followupsAccepted = Promise.withResolvers() const persistence = ctx.sessionPersistence const load = persistence.load.bind(persistence) + // The unavailable-child lookup is real asynchronous I/O. Fence it behind both + // authored follow-ups so runner speed cannot reorder the exact log. persistence.load = async (id) => { - log('load', id) - if (id === UNKNOWN_CHILD_ID) { log('gating unknown-id load'); await followupsAccepted.promise; log('unknown-id load released') } + if (id === UNKNOWN_CHILD_ID) await followupsAccepted.promise return load.call(persistence, id) } - - // Patch followup to log routing. - const subagents = ctx.subagents as unknown as { followup: (...a: unknown[]) => Promise } - const origFollowup = subagents.followup.bind(subagents) - subagents.followup = async (...args: unknown[]) => { - log('followup childId=', args[1]) - return origFollowup(...args) - } - ctx.effect(() => () => { persistence.load = load followupsAccepted.resolve(undefined) }, 'subagent snapshot ordering') + // Remap the placeholder child id in a follow-up to the live child. The child + // id the model "knows" is authored into the transcript, while the running + // child is minted with a random id, so without this the follow-ups would + // never reach the live inbox. + let realChildId: string | undefined + const subagents = ctx.subagents as unknown as { + followup: (authority: unknown, childId: SessionId, content: unknown, options: unknown) => Promise + } + const deliver = subagents.followup.bind(subagents) + subagents.followup = (authority, childId, content, options) => { + const mapped = childId === PLACEHOLDER_CHILD_ID && realChildId !== undefined + ? SessionId(realChildId) + : childId + return deliver(authority, mapped, content, options) + } + + // Both authored follow-ups reach the child inbox before the unknown-id lookup + // runs, so the queued FIFO order is what the transcript records. The first + // child enqueue is the initial delegation, which also pins the real child id. let accepted = 0 ctx.on('agent/inbox/enqueue', (agent) => { if (agent.session.header.parentSession === undefined) return + if (realChildId === undefined) realChildId = agent.session.header.id accepted += 1 - log('child enqueue #', accepted, 'child=', agent.session.header.id) if (accepted >= 3) followupsAccepted.resolve(undefined) }) - ctx.on('subagent/start', (info: unknown) => { - log('subagent/start id=', (info as { id?: unknown }).id) - }) - + // The child's ordinary per-turn flushes succeed; only the final continuation + // turn's durability checkpoint fails, turning that turn/end into a durable + // error the parent never sees. + const childTurn = new WeakMap() ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined) return - if (event.type === 'turn/start') log('child turn/start turn=', event.data.turn, 'child=', session.header.id) - if (event.type === 'user/message') { - const c = event.data.content?.[0] - log('child user/message text=', c && c.type === 'text' ? c.text : '?', 'child=', session.header.id) - } + if (session.header.parentSession === undefined || event.type !== 'turn/start') return + childTurn.set(session, event.data.turn) }) - - const flushes = new WeakMap() - const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return - const count = (flushes.get(session) ?? 0) + 1 - flushes.set(session, count) - log('child flush #', count, 'child=', session.header.id) - if (session.events.at(-1)?.type !== 'turn/end') return - if (flushedTurnEnds.has(session)) { - log('THROW snapshot disk full') - throw new Error('snapshot disk full') - } - flushedTurnEnds.add(session) + if (childTurn.get(session) === FAILED_CHECKPOINT_TURN) throw new Error('snapshot disk full') }) } diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 67451e499b..a5220ed410 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -2,39 +2,30 @@ {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} {"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} {"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}},"seq":3,"time":1789000000004} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":4,"time":1789000000005} -{"type":"step/start","data":{"turn":2,"step":1},"seq":5,"time":1789000000006} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":6,"time":1789000000007} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":7,"time":1789000000008} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}},"seq":8,"time":1789000000009} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}},"seq":9,"time":1789000000010} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":10,"time":1789000000011} -{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":11,"time":1789000000012} -{"type":"assistant/message","data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":12,"time":1789000000013} -{"type":"step/end","data":{"turn":2,"step":1},"seq":13,"time":1789000000014} -{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":14,"time":1789000000015} -{"type":"turn/start","data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":15,"time":1789000000016} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":16,"time":1789000000017} -{"type":"step/start","data":{"turn":3,"step":1},"seq":17,"time":1789000000018} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":18,"time":1789000000019} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":19,"time":1789000000020} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}},"seq":20,"time":1789000000021} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}},"seq":21,"time":1789000000022} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":22,"time":1789000000023} -{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":23,"time":1789000000024} -{"type":"assistant/message","data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":24,"time":1789000000025} -{"type":"step/end","data":{"turn":3,"step":1},"seq":25,"time":1789000000026} -{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":26,"time":1789000000027} -{"type":"turn/start","data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}},"seq":27,"time":1789000000028} -{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append","seq":28,"time":1789000000029} -{"type":"step/start","data":{"turn":4,"step":1},"seq":29,"time":1789000000030} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"},"seq":30,"time":1789000000031} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":31,"time":1789000000032} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"text-delta","index":0,"text":"THIRD_OK"}},"seq":32,"time":1789000000033} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"THIRD_OK"}}},"seq":33,"time":1789000000034} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":34,"time":1789000000035} -{"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":35,"time":1789000000036} -{"type":"assistant/message","data":{"turn":4,"step":1,"content":[{"type":"text","text":"THIRD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":36,"time":1789000000037} -{"type":"step/end","data":{"turn":4,"step":1},"seq":37,"time":1789000000038} -{"type":"turn/end","data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}},"seq":38,"time":1789000000039} +{"type":"turn/start","seq":3,"time":1789000000004,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":4,"time":1789000000005,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1789000000005,"data":{"title":"Reply with exactly the word","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":6,"time":1789000000006,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":7,"time":1789000000007,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":8,"time":1789000000008,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000010,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":11,"time":1789000000011,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1789000000012,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":13,"time":1789000000013,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1789000000014,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":15,"time":1789000000015,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":16,"time":1789000000016,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":17,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} +{"type":"step/start","seq":18,"time":1789000000018,"data":{"turn":3,"step":1}} +{"type":"assistant/chunk","seq":19,"time":1785394678743,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":1789000000020,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":21,"time":1789000000021,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":22,"time":1789000000022,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1789000000023,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1785394678743,"data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785394678743,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":26,"time":1785394678743,"data":{"turn":3,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":27,"time":1785394678756,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":28,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} +{"type":"turn/end","seq":29,"time":1785394678762,"data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 1b28826e21..4dff6a044a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,58 +1,54 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Collect its result with task_output using the task id from the acknowledgement and wait: true. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.'. 4. Collect the task it started with task_output and wait: true, and observe that it failed. 5. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1789000000004} +{"type":"step/start","seq":3,"time":1789000000004,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":5,"time":1789000000006} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}},"seq":6,"time":1789000000007} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}},"seq":7,"time":1789000000008} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":8,"time":1789000000009} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":9,"time":1789000000010} -{"type":"assistant/message","data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":10,"time":1789000000011} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"},"seq":11,"time":1789000000012} -{"type":"tool/result","data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}]},"seq":12,"time":1789000000013} -{"type":"step/end","data":{"turn":1,"step":1},"seq":13,"time":1789000000014} -{"type":"step/start","data":{"turn":1,"step":2},"seq":14,"time":1789000000015} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":15,"time":1789000000016} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":16,"time":1789000000017} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}},"seq":17,"time":1789000000018} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}},"seq":18,"time":1789000000019} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":19,"time":1789000000020} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":20,"time":1789000000021} -{"type":"assistant/message","data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":21,"time":1789000000022} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"},"seq":22,"time":1789000000023} -{"type":"tool/result","data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":23,"time":1789000000024} -{"type":"step/end","data":{"turn":1,"step":2},"seq":24,"time":1789000000025} -{"type":"step/start","data":{"turn":1,"step":3},"seq":25,"time":1789000000026} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":26,"time":1789000000027} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":27,"time":1789000000028} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}},"seq":28,"time":1789000000029} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}},"seq":29,"time":1789000000030} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":30,"time":1789000000031} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":31,"time":1789000000032} -{"type":"assistant/message","data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":32,"time":1789000000033} -{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"},"seq":33,"time":1789000000034} -{"type":"tool/result","data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}]},"seq":34,"time":1789000000035} -{"type":"step/end","data":{"turn":1,"step":3},"seq":35,"time":1789000000036} -{"type":"step/start","data":{"turn":1,"step":4},"seq":36,"time":1789000000037} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":37,"time":1789000000038} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},"seq":38,"time":1789000000039} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}},"seq":39,"time":1789000000040} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}},"seq":40,"time":1789000000041} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":41,"time":1789000000042} -{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}},"seq":42,"time":1789000000043} -{"type":"assistant/message","data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":43,"time":1789000000044} -{"type":"tool/call","data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"},"seq":44,"time":1789000000045} -{"type":"tool/result","data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true},"seq":45,"time":1789000000046} -{"type":"step/end","data":{"turn":1,"step":4},"seq":46,"time":1789000000047} -{"type":"step/start","data":{"turn":1,"step":5},"seq":47,"time":1789000000048} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"continuation"},"seq":48,"time":1789000000049} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}},"seq":49,"time":1789000000050} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}},"seq":50,"time":1789000000051} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},"seq":51,"time":1789000000052} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},"seq":52,"time":1789000000053} -{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}},"seq":53,"time":1789000000054} -{"type":"assistant/message","data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"seq":54,"time":1789000000055} -{"type":"step/end","data":{"turn":1,"step":5},"seq":55,"time":1789000000056} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":56,"time":1789000000057} +{"type":"assistant/chunk","seq":5,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1789000000011,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1789000000012,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":12,"time":1789000000013,"data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1789000000014,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1789000000015,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785394678688,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":1785394678689,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1785394678689,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} +{"type":"tool/result","seq":22,"time":1785394678701,"data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785394678701,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1785394678713,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1785394678718,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1785394678719,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1785394678719,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785394678719,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} +{"type":"tool/result","seq":32,"time":1785394678733,"data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1785394678733,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1785394678746,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1785394678752,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1785394678753,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785394678753,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":42,"time":1785394678765,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true,"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1785394678765,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1785394678774,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1785394678778,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1785394678779,"data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785394678779,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":52,"time":1785394678779,"data":{"turn":1,"reason":{"kind":"completed"}}} From 03973cb074bbd8439182c503ada91e5e14a9b9e5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:09:12 +0800 Subject: [PATCH 13/66] test(acp-agent): refresh header pins for the new subagent tool wording Every scenario compares its live tool schemas and system prompt against the shared header pins, so the Task-free subagent and send_message descriptions change all 14 pin sidecars. The diff is only that wording plus the tools' output-type shapes. --- .../system-prompt.expected.md | 21 +++++++++++-------- .../tool-schemas.expected.json | 10 ++++----- .../both-mode-turn/tool-schemas.expected.json | 10 ++++----- .../code-mode-turn/system-prompt.expected.md | 21 +++++++++++-------- .../lsp-definition/tool-schemas.expected.json | 10 ++++----- .../pty-tools/tool-schemas.expected.json | 10 ++++----- .../tool-schemas.expected.json | 10 ++++----- .../text-turn/tool-schemas.expected.json | 10 ++++----- .../web-fetch/tool-schemas.expected.json | 10 ++++----- 9 files changed, 59 insertions(+), 53 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index e386a25eff..b1c44a10ea 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -110,7 +110,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -122,22 +122,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -317,8 +317,7 @@ interface ToolOutputMap { totalLines: number; }; send_message: { - route: "steered" | "started"; - taskId: string; + messageId: string; }; skill: { name: string; @@ -338,7 +337,9 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; @@ -347,7 +348,9 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 00ae670288..7863590b88 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -239,7 +239,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -276,7 +276,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -290,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -301,7 +301,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -315,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 6d052ead19..585c601a84 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -182,7 +182,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -219,7 +219,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -233,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -244,7 +244,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -258,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 15b5e8dde6..e53e20d3ad 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -93,7 +93,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */ + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -105,22 +105,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ + /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -288,8 +288,7 @@ interface ToolOutputMap { totalLines: number; }; send_message: { - route: "steered" | "started"; - taskId: string; + messageId: string; }; skill: { name: string; @@ -309,7 +308,9 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; @@ -318,7 +319,9 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; - subagentId?: string; + } | { + kind: "continuable"; + subagentId: string; } | { kind: "foreground"; runId: string; diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 6124557f08..dd4be5f915 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -235,7 +235,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -249,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -260,7 +260,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -274,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d4c004034f..d80fe4b555 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 72c6b74b72..59bb91d9ac 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -402,7 +402,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -416,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -427,7 +427,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -441,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index bf0103bbeb..ee1c0e158b 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index d143e9d82a..c94b51630d 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." + "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." } }, "required": [ From 542a01c40738d3211a45a2570e945c66019900be Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:15:38 +0800 Subject: [PATCH 14/66] test(acp): cover the continuable drain ordering and its failure path Pins that the bridge releases the Activation forest before its own sessions, and that a failed drain is reported without stranding that teardown. Reads the one teardown method structurally so the bridge keeps no dependency on the subagent seam. --- packages/acp/acp/src/index.ts | 14 +++++++++- packages/acp/acp/tests/dispose.spec.ts | 38 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d76a184f5e..d19a2e5753 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -43,6 +43,16 @@ export const name = 'acp' /** The bridge creates and owns agents; every other concern is carried by the agent composition. */ export const inject = ['agents'] +/** + * The single continuable-subagent teardown the bridge needs. Declared + * structurally so this package does not depend on the subagent seam for one + * shutdown hook; an absent service means nothing continuable was materialized. + */ +interface ContinuableDrain { + /** Close continuable admission, then dispose every live Activation child-first. */ + drainContinuable(): Promise +} + /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) @@ -331,7 +341,9 @@ export function apply(ctx: Context, config: AcpConfig): void { // Activations own descendant teardown. Drain that forest child-first // BEFORE disposing the top-level agents, so no descendant is left holding // a runtime its owner already released. - const subagents = ctx.get('subagents') + // Read the one teardown method structurally: the bridge needs no other + // part of the subagent seam, so it does not depend on that package. + const subagents = ctx.get('subagents') as ContinuableDrain | undefined if (subagents !== undefined) { try { await subagents.drainContinuable() diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index ee57d51baf..64bab702b3 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -25,6 +25,44 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('drains continuable subagents before disposing its own sessions', async () => { + harness = await makeBridgeHarness() + const order: string[] = [] + // A continuable Activation outlives the turn that started it, so the bridge + // must release that forest before the agents whose runtime it depends on. + harness.ctx.provide('subagents', { + drainContinuable: () => { + order.push('drained') + return Promise.resolve() + }, + } as never, true) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) + + await harness.acpFiber.dispose() + + expect(order).toEqual(['drained', 'agent disposed']) + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + + it('reports a failed continuable drain and still disposes its sessions', async () => { + harness = await makeBridgeHarness() + const warnings: string[] = [] + harness.ctx.logger.warn = (message: string) => { warnings.push(message) } + harness.ctx.provide('subagents', { + drainContinuable: () => Promise.reject(new Error('activation teardown failed')), + } as never, true) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.acpFiber.dispose() + + // A stuck descendant must not strand the bridge's own teardown. + expect(warnings.some(warning => warning.includes('continuable subagent teardown failed'))).toBe(true) + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + it('an ACP-only reload rejects new sessions before creating an orphan', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) From 19d034169e6b22d3e90ba9da4d082a9a021b669d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 15:19:53 +0800 Subject: [PATCH 15/66] docs: regenerate the config catalog source line The ACP bridge's new structural teardown type shifts its Config declaration. --- docs/config-catalog.md | 2 +- packages/acp/acp/tests/dispose.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 29ba89cbef..9b7be60536 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:57`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:67`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 64bab702b3..b7b38b0ffb 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -35,7 +35,7 @@ describe('ACP connection ownership', () => { order.push('drained') return Promise.resolve() }, - } as never, true) + } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) @@ -52,7 +52,7 @@ describe('ACP connection ownership', () => { harness.ctx.logger.warn = (message: string) => { warnings.push(message) } harness.ctx.provide('subagents', { drainContinuable: () => Promise.reject(new Error('activation teardown failed')), - } as never, true) + } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) From c485b6136d6411c898a3b2e1fad8c559daf1140d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:01:11 +0800 Subject: [PATCH 16/66] fix(subagent): address codex review round 1 All five findings were real: - The terminal lifecycle edge derived its stop reason from teardown success, so a child that errored, hit its token ceiling, or was cancelled reported as completed once its checkpoint and disposal succeeded. It now reads the child's own last message turn/end, which is authoritative. - Live delivery never rechecked the caller signal after authorization yielded, so an abort that won before acceptance still enqueued the message and returned an id. Admission now re-checks at the boundary that owns the decision. - Drain flushed before cancelling, letting a running turn keep appending events the checkpoint could not cover and letting model work continue through a slow flush. It now cancels to quiescence first. - subagent/end fired after AgentHandle.dispose() unregistered the child, so the hooks bridge could not resolve it for the child's cwd and scope. The edge now publishes while the child is still registered. - activationState() read Agent.status alone, which stays idle between an accepted waking send and the microtask that admits it, so a synchronous inbox observer could see settled with a queued turn. Residency now also counts messages this manager admitted but has not seen leave the inbox. --- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 +- .../subagent/subagent/src/continuation.ts | 57 ++++++- packages/subagent/subagent/src/index.ts | 34 +++- .../subagent/tests/continuation.spec.ts | 146 ++++++++++++++++++ 6 files changed, 243 insertions(+), 16 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 877a6a44f2..4d08e8221a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:141`](../../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:114`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:115`](../../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:120`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:121`](../../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:131`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 11ba7fb59b..6e8ab17396 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2033,7 +2033,7 @@ async start(name: string, request: SubagentStartRequest): Promise 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) -Source: [`packages/subagent/subagent/src/index.ts:173`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:174`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 696f4f8f66..d41a6b329b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,8 +12,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | @@ -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:140`](../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:114`](../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:120`](../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:131`](../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:141`](../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:115`](../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:121`](../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:132`](../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`) | - | diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3ab4d342ef..b7e0d87274 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -168,6 +168,12 @@ interface Activation { * a new Activation. Every converging releaser shares this one teardown. */ disposal: Promise | undefined + /** + * Accepted waking message ids this manager has not yet seen leave the inbox. + * `Agent.status` is still `idle` in the window between `followup()` and the + * microtask that admits it, so settlement must not treat that gap as quiet. + */ + readonly accepted: Set /** Renewed whenever a settlement watcher must re-observe quiescence. */ poke: PromiseWithResolvers } @@ -353,6 +359,11 @@ export class SubagentContinuationManager { return activation.disposal.then(() => undefined, () => undefined) } await this.authorizeLive(authority, 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) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that @@ -413,10 +424,15 @@ export class SubagentContinuationManager { /** * Derive residency from Agent quiescence and the owned-child set. `running` - * covers an active admission, an open turn, or waking inbox work. + * covers an active admission, an open turn, or accepted waking inbox work. + * + * `Agent.status` alone is insufficient: it stays `idle` between an accepted + * waking send and the microtask that admits it, so a synchronous inbox + * observer would see `settled` while a turn is already queued. `accepted` + * holds the ids this manager admitted but has not yet seen drained. */ private stateOf(activation: Activation): ActivationState { - if (activation.handle.agent.status === 'running') return 'running' + if (activation.handle.agent.status === 'running' || activation.accepted.size > 0) return 'running' if (activation.ownedChildren.size > 0) return 'waiting' return 'settled' } @@ -528,6 +544,7 @@ export class SubagentContinuationManager { ownedChildren: new Set(), observer, disposal: undefined, + accepted: new Set(), poke: Promise.withResolvers(), } // After transfer, any failure must dispose the created handle, remove the @@ -550,6 +567,22 @@ export class SubagentContinuationManager { await activation.disposal.catch(() => undefined) throw error } + // Every accepted id leaves the inbox exactly once, through dequeue or + // discard. Clearing it there is what lets `stateOf()` distinguish a truly + // quiet Agent from one whose accepted turn has not been admitted yet. + // Registered through the child's own scoped context, so scope filtering + // already restricts both listeners to this exact agent. + handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { + /* v8 ignore next -- a dequeue of an id this manager never admitted needs + * another sender on the same child, which no current path allows. */ + if (activation.accepted.delete(item.message.id)) this.wake(activation) + }) + handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { + // Deleting every id in the batch is unconditional; waking once afterwards + // costs nothing and avoids branching on which ids this manager admitted. + for (const item of items) activation.accepted.delete(item.message.id) + this.wake(activation) + }) // Resident: publish the start edge before any turn can run, so observers // see this epoch before its first request. observer.start() @@ -604,7 +637,15 @@ export class SubagentContinuationManager { // establish it before the message can enter the child's inbox. if (authority.kind === 'parent') this.acquireOwnership(authority.agent, activation.childId) const message = createUserMessage({ content, source }) - activation.handle.agent.followup(message) + // `Agent.followup()` publishes `agent/inbox/enqueue` synchronously, so its + // observers must see this Activation as busy before the call begins. + activation.accepted.add(message.id) + try { + activation.handle.agent.followup(message) + } catch (error: unknown) { + activation.accepted.delete(message.id) + throw error + } // Accepted waking work keeps this Activation live until whenIdle() observes // the complete waking suffix. this.wake(activation) @@ -730,8 +771,17 @@ export class SubagentContinuationManager { 'ACTIVATION_TEARDOWN_FAILED', ) } + // Quiesce before the checkpoint: a turn still running would keep + // appending events the flush cannot cover, and a slow flush would let + // model and tool work continue for the whole shutdown. + activation.handle.agent.cancel({ kind: 'parent' }) + await activation.handle.agent.whenIdle() const durability = await this.checkpoint(activation) failure ??= durability + // Publish the terminal edge while the child is STILL registered: + // consumers resolve `ctx.agents.get(info.id)` in `subagent/end` to run + // in the child's own cwd and scope, which handle disposal removes. + activation.observer.settle(activation.handle.agent, failure) } finally { this.activations.delete(childId) try { @@ -746,7 +796,6 @@ export class SubagentContinuationManager { // Release ownership even on failure: a retained failed child would // pin its ancestors in `waiting` forever. this.releaseOwnership(childId) - activation.observer.settle(activation.handle.agent, failure) } } if (failure !== undefined) throw failure diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0789113e7e..03bea866a5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -36,6 +36,7 @@ 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 { ContinuableCreateRequest, @@ -377,7 +378,7 @@ export class SubagentService extends Service { const output = failure === undefined ? lastAssistantOutput(child) : undefined this.emitLifecycle('subagent/end', { ...identity, - stopReason: failure === undefined ? 'completed' : 'error', + stopReason: failure === undefined ? childStopReason(child) : 'error', ...output === undefined ? {} : { lastAssistantMessage: output }, }, parent) }, @@ -459,6 +460,37 @@ 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 child - the settling child agent whose log is read. + * @returns its terminal stop reason; `completed` when no ordinary turn closed. + */ +function childStopReason(child: Agent): SubagentResult['stopReason'] { + const reason = findLastMessageTurnEnd(child.session.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. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 612c6763e8..feb18d196a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -643,6 +643,152 @@ describe('continuable durability and teardown', () => { }) }) +describe('continuable review regressions', () => { + it('reports the child\'s own terminal reason, not teardown success', async () => { + // The child hits its token ceiling; teardown still succeeds. + const { ctx, parent } = await setupWith(new MockAdapter([ + [{ type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'partial' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } }, + { type: 'finish', reason: { kind: 'max-tokens' } }], + ])) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Deriving this from disposal success would report the failure as completed. + expect(ends[0]!.stopReason).toBe('max-tokens') + }) + + it('rejects a live delivery whose caller signal aborted before admission', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const before = child.session.events.length + + const controller = new AbortController() + controller.abort('caller gave up') + await expect(followup(ctx, { kind: 'user' }, started.childId, message('cancelled'), controller.signal)) + .rejects.toThrow() + + // Nothing was enqueued, so no later turn can carry it. + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'cancelled')).toBe(false) + expect(before).toBeGreaterThan(0) + }) + + it('publishes the terminal edge while the child agent is still resolvable', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const resolvable: boolean[] = [] + // Consumers resolve the child in `subagent/end` to run in its own cwd. + ctx.on('subagent/end', (info) => { + resolvable.push(ctx.agents.get(info.id) !== undefined) + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(resolvable).toHaveLength(1) }) + expect(resolvable[0]).toBe(true) + }) + + it('cancels a running turn before the final durability checkpoint', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const order: string[] = [] + ctx.on('session/flush', (session) => { + if (session.header.parentSession !== undefined) order.push('flush') + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const child = await vi.waitFor(() => { + const found = ctx.agents.get(started.childId) + expect(found).toBeDefined() + return found! + }) + child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + + // Flushing a still-running turn cannot cover the events cancellation adds. + expect(order.indexOf('cancel')).toBeGreaterThanOrEqual(0) + expect(order.indexOf('cancel')).toBeLessThan(order.lastIndexOf('flush')) + }) + + it('releases an accepted message that is discarded instead of run', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // 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, { kind: 'user' }, started.childId, message('discarded')) + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'discarded')).toBe(false) + }) + + it('reports completed when no ordinary turn closed', async () => { + const { ctx, parent } = await setup([]) + 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) => { + if (subject === parent) return next() + return { kind: 'block', reason: 'blocked by policy' } + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('completed') + }) + + it('never reports settled while an accepted message is still in the inbox', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const states: (string | undefined)[] = [] + // 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)) + } + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + await followup(ctx, { kind: 'user' }, started.childId, message('queued')) + + expect(states.length).toBeGreaterThan(0) + expect(states).not.toContain('settled') + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) +}) + describe('continuable lifecycle observation', () => { it('emits one paired start/end per residency epoch', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) From cbaceb73a98a6ef41c06d72a186f1911fb64c151 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:18:15 +0800 Subject: [PATCH 17/66] fix(subagent): address codex review round 2 Round 1 traded one teardown ordering problem for another. The observer now splits capture from emission, which satisfies both consumers at once: - Terminal facts are captured while the child is still registered, so consumers that resolve it for the child's log and scope still work. - The edge is emitted only after handle disposal settles, so a rejecting scoped cleanup is reported as a failed epoch instead of a successful one. Also: - Keep the Activation in the map until disposal settles. Removing it first let a racing followup() see no Activation and cold-resume into the still-registered agent, and let a concurrent forest drain skip a still-disposing child and release its parent first. - Derive terminal telemetry from this epoch's event suffix rather than the whole session, so a cold resume whose prompt is blocked no longer reports the previous epoch's answer and turn reason. - Cancel the ACP bridge's own prompts before awaiting the descendant drain: a drain can block on persistence, and the top-level agents must not keep running model and tool work for its whole duration. --- packages/acp/acp/src/index.ts | 12 ++-- packages/acp/acp/tests/dispose.spec.ts | 28 ++++++++ .../subagent/subagent/src/continuation.ts | 42 +++++++---- packages/subagent/subagent/src/index.ts | 38 +++++++--- .../subagent/tests/continuation.spec.ts | 70 ++++++++++++++++--- 5 files changed, 153 insertions(+), 37 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d19a2e5753..2c877df7c4 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -336,6 +336,13 @@ export function apply(ctx: Context, config: AcpConfig): void { closed = true const records = [...sessions.values()] sessions.clear() + // Stop the bridge's own work before any await: a descendant drain can block + // on persistence or scoped cleanup, and the top-level agents must not keep + // running model and tool calls for its whole duration. + for (const record of records) { + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + } quiescing = (async () => { // Continuable subagents outlive the turn that started them, and their // Activations own descendant teardown. Drain that forest child-first @@ -351,10 +358,7 @@ export function apply(ctx: Context, config: AcpConfig): void { logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) } } - await Promise.all(records.map(async (record) => { - settlePrompt(record, 'cancelled') - await record.dispose() - })) + await Promise.all(records.map(record => record.dispose())) })() return quiescing } diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index b7b38b0ffb..4be0810513 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -46,6 +46,34 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('cancels its own prompt before awaiting the descendant drain', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const order: string[] = [] + const release = Promise.withResolvers() + harness.ctx.provide('subagents', { + drainContinuable: async () => { + order.push('drain started') + await release.promise + order.push('drain finished') + }, + } as never) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) + await vi.waitFor(() => { expect(agent.status).toBe('running') }) + harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') }) + + const disposal = harness.acpFiber.dispose() + // A drain can block on persistence, so the bridge's own turn must already be + // cancelled rather than running for its whole duration. + await vi.waitFor(() => { expect(order).toContain('drain started') }) + expect(order).toEqual(['parent cancelled', 'drain started']) + release.resolve(undefined) + await disposal + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + it('reports a failed continuable drain and still disposes its sessions', async () => { harness = await makeBridgeHarness() const warnings: string[] = [] diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index b7e0d87274..9974a355a4 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -108,17 +108,26 @@ export type ActivationState = 'running' | 'waiting' | 'settled' * children emit the same start/end pair as one-shot runs. */ export interface ActivationObserver { - /** Publish the start edge once the epoch is resident. */ - start(): void /** - * Publish the terminal edge exactly once, pairing this epoch's {@link start}. - * Called only for a resident epoch: a failure before residency publishes no - * edge at all, because inventing one would report a lifecycle the child never - * had. - * @param child - the child agent whose final output the edge reports. + * 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(child: Agent, failure: unknown): void + settle(failure: unknown): void } /** Hooks the manager needs from the owning service. */ @@ -585,7 +594,7 @@ export class SubagentContinuationManager { }) // Resident: publish the start edge before any turn can run, so observers // see this epoch before its first request. - observer.start() + observer.start(handle.agent) this.watchSettlement(activation) return activation } @@ -778,12 +787,10 @@ export class SubagentContinuationManager { await activation.handle.agent.whenIdle() const durability = await this.checkpoint(activation) failure ??= durability - // Publish the terminal edge while the child is STILL registered: - // consumers resolve `ctx.agents.get(info.id)` in `subagent/end` to run - // in the child's own cwd and scope, which handle disposal removes. - activation.observer.settle(activation.handle.agent, failure) + // Capture the child-dependent edge data while the child is still live: + // handle disposal unregisters it, and consumers read its log and scope. + activation.observer.capture(activation.handle.agent) } finally { - this.activations.delete(childId) try { await activation.handle.dispose() } catch (error: unknown) { @@ -793,9 +800,16 @@ export class SubagentContinuationManager { { cause: error }, ) } finally { + // Only now is the Activation gone: keeping the entry until disposal + // settles makes a racing delivery wait for release rather than + // cold-resume into the still-registered agent. + this.activations.delete(childId) // Release ownership even on failure: a retained failed child would // pin its ancestors in `waiting` forever. this.releaseOwnership(childId) + // Emit once the disposal outcome is known, so a rejecting scoped + // cleanup cannot be reported as a successful epoch. + activation.observer.settle(failure) } } if (failure !== undefined) throw failure diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 03bea866a5..a5e120c860 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -363,22 +363,40 @@ export class SubagentService extends Service { parent: Agent | undefined, ): 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: (): void => { + start: (child: Agent): void => { + boundary = child.session.events.length this.emitLifecycle('subagent/start', identity, parent) }, - settle: (child: Agent, failure: unknown): void => { + 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 ? lastAssistantOutput(child) : undefined + const output = failure === undefined ? captured.output : undefined this.emitLifecycle('subagent/end', { ...identity, - stopReason: failure === undefined ? childStopReason(child) : 'error', + stopReason: failure === undefined ? captured.stopReason : 'error', ...output === undefined ? {} : { lastAssistantMessage: output }, }, parent) }, @@ -465,11 +483,11 @@ export class SubagentService extends Service { * 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 child - the settling child agent whose log is read. + * @param events - this epoch's own event suffix. * @returns its terminal stop reason; `completed` when no ordinary turn closed. */ -function childStopReason(child: Agent): SubagentResult['stopReason'] { - const reason = findLastMessageTurnEnd(child.session.events)?.data.reason +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) { @@ -494,11 +512,11 @@ function childStopReason(child: Agent): SubagentResult['stopReason'] { /** * The child's last assistant message content, for one Activation's terminal * lifecycle edge. Absent when no assistant message reached the log. - * @param child - the settling child agent whose log is read. + * @param events - this epoch's own event suffix. * @returns its final assistant content, or `undefined` when it produced none. */ -function lastAssistantOutput(child: Agent): ContentBlock[] | undefined { - const message = child.session.events.findLast( +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 diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index feb18d196a..c20cc175a9 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -685,19 +685,71 @@ describe('continuable review regressions', () => { expect(before).toBeGreaterThan(0) }) - it('publishes the terminal edge while the child agent is still resolvable', async () => { - const { ctx, parent } = await setup([textResponse('answer')]) - const resolvable: boolean[] = [] - // Consumers resolve the child in `subagent/end` to run in its own cwd. - ctx.on('subagent/end', (info) => { - resolvable.push(ctx.agents.get(info.id) !== undefined) - }) + it('reports this epoch\'s own output, captured while the child was still live', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Handle disposal unregisters the child, so the edge's content must have + // been captured before that — an after-the-fact lookup would find nothing. + expect(ends[0]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'first answer' }]) - await vi.waitFor(() => { expect(resolvable).toHaveLength(1) }) - expect(resolvable[0]).toBe(true) + // 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, { kind: 'user' }, 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' }]) + }) + + it('reports a resumed epoch that opened no turn without the previous answer', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + 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) => { + if (subject === parent) return next() + return { kind: 'block', reason: 'blocked by policy' } + }) + await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Reading the whole session would resurrect 'first answer' here. + expect(ends[0]!.lastAssistantMessage).toBeUndefined() + expect(ends[0]!.stopReason).toBe('completed') + }) + + it('reports handle-disposal failure on the terminal edge', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = await vi.waitFor(() => { + const found = manager.activations.get(started.childId) + expect(found).toBeDefined() + return found! + }) + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('scoped cleanup failed') + } + + await expect(ctx.subagents.drainContinuable()).rejects.toThrow() + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + // Emitting before disposal would have reported this failed epoch as success. + expect(ends[0]!.stopReason).toBe('error') }) it('cancels a running turn before the final durability checkpoint', async () => { From 7428cdf41e8680d949ae173f3fd6f39b9107e56f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 16:48:42 +0800 Subject: [PATCH 18/66] fix(subagent): address codex review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make host-user authority unforgeable. `{ kind: 'user' }` was a bare discriminant, so any plugin holding `ctx.subagents` — including model-generated cordis_mount code, which the advanced ACP composition ships alongside continuable subagents — could construct it and skip the direct-parent check for any known child id. It now carries an opaque grant that only SubagentService.userAuthority() mints, which composition hands to trusted host adapters; a model-facing tool uses parent authority from its own execution context. - Reconcile a delivery discarded inside its own admission window. An enqueue listener that cancels fires the discard before followup() returns, so the discard listener could not clear an id it had not seen; submit() retained it and residency stayed `running` until an explicit drain. - Recheck the caller signal after materialization. An abort landing between publication and inbox acceptance still submitted the prompt and returned both ids; it now rolls the child back. - Stop promising the model transcript access that no shipped continuable config mounts. The tools now state only that a background child does not report back. - Restate the implemented note as shipped state rather than a proposal, so it works as current authority. --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 40 +++---- ...8-continuable-subagent-conversations.zh.md | 40 +++---- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 11 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 12 ++- docs/core-data-structures/subagent.zh.md | 10 +- docs/event-producer-consumer.md | 8 +- docs/tool-catalog.md | 2 +- .../system-prompt.expected.md | 10 +- .../tool-schemas.expected.json | 10 +- .../both-mode-turn/tool-schemas.expected.json | 10 +- .../code-mode-turn/system-prompt.expected.md | 10 +- .../lsp-definition/tool-schemas.expected.json | 10 +- .../pty-tools/tool-schemas.expected.json | 10 +- .../tool-schemas.expected.json | 10 +- .../text-turn/tool-schemas.expected.json | 10 +- .../web-fetch/tool-schemas.expected.json | 10 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- .../subagent/subagent/src/continuation.ts | 50 ++++++++- packages/subagent/subagent/src/index.ts | 24 ++++- .../subagent/tests/continuation.spec.ts | 101 ++++++++++++++---- .../subagent/subagent/tests/service.spec.ts | 2 +- .../tool-subagent-control/src/index.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 8 +- 29 files changed, 297 insertions(+), 141 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index f9a3bfc16c..7e2a36b502 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -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: 5ab17ea13d15d66afab4fee6766b082dd207b8a3 -2026-07-28-continuable-subagent-conversations.zh.md: eb14ebcec9682432682f6b5b4d8399f35b6882a2 +2026-07-28-continuable-subagent-conversations.md: a56da8ad389964dcc873a722a66e335062811f37 +2026-07-28-continuable-subagent-conversations.zh.md: 71089cd71ae6fda7712ffcc614852a483e13e3ba diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 5ab17ea13d..a56da8ad38 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -4,13 +4,13 @@ Status: implemented English | [中文](2026-07-28-continuable-subagent-conversations.zh.md) -This proposal would replace the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). +This record replaces the Task-backed continuation manager from [Continuable background subagents](../../implemented/feature/2026-07-21-continuable-background-subagents.md). It retains the single `ctx.subagents` service from [Merge subagent control into the subagent service](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md) and the intent-named `followup` operation from [Intent-named subagent continuation operations](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md). ## Problem -The continuation manager currently makes one Task, one provider execution, and one result boundary the same object lifetime. Task settlement disposes the child Agent, Task completion injects the completion notice, and later input reconstructs another Agent. This couples a generic background-work abstraction to conversation delivery even though a continuable subagent already has a Session and an Agent inbox. +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 creates two FIFOs with no single ordering authority. Giving both to Tasks instead duplicates 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 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. 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. @@ -30,7 +30,7 @@ persisted Session An Activation is one residency epoch for a reconstructed child Agent. It may execute multiple FIFO turns and remain resident while waiting for descendants. It is not a request, result, cancellation, or Task boundary. -The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. The proposal creates no Task for a continuable subagent, no Activation FIFO, and no queued Activation state. +The continuation manager owns activation admission, authority checks, the live ownership graph, cold resume, and child-first disposal. The Agent loop owns all turn ordering and execution. No continuable subagent has a Task, an Activation FIFO, or queued Activation state. ### Materialization and public operations @@ -54,7 +54,7 @@ The Session owns the stable child identity, transcript, direct-parent lineage, d 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. -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 outside the MVP and require a separate Activation ownership contract when introduced. Historical Sessions consume no runtime memory after their Activation is disposed. +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 @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha 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. -The MVP retains ownership 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. +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. @@ -115,13 +115,13 @@ The activation-owner scope exists because ordinary Cordis owner effects unwind i ### Deferred report delivery -The MVP exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. +This version exposes no `report` tool and provides no child-to-parent content delivery or automatic parent wakeup. The durable child Session remains the source of the child's detailed output. A later proposal may add an ordinary model-facing `report(output)` tool that can be called zero or multiple times in one turn. Its delivery policy may distinguish quiet parent injection from waking the parent; recipient selection, acknowledgement, durability, and retry semantics are deferred with that tool. Adding report delivery does not require another Activation state or execution queue. ### Deferred steering -The MVP 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 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. 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. @@ -129,13 +129,13 @@ A later host UI may expose separate **Steer** and **Follow up** actions. User st 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. -The MVP 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 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. 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. ### 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 MVP 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, 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. 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. @@ -147,9 +147,9 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo ### Scope -The MVP 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. +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. -The MVP 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 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. ## Alternatives considered @@ -159,9 +159,9 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **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 MVP 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 and would complicate user cold resume with an unnecessary live-parent input. -**Add report delivery to the MVP.** 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. +**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. **Treat `SessionHeader.parentSession` as live ownership.** Durable lineage does not prove that the historical parent currently owns the child. Membership in the live parent's `ownedChildren` records the process-local relationship without changing durable provenance. @@ -169,7 +169,7 @@ The MVP adds no subagent steering operation, report tool, child-to-parent conten **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. -**Expose subagent steering in the MVP.** 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.** 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. **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. @@ -189,14 +189,14 @@ The implementation pins these behaviors: - `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. - `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. -- The MVP 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. -- The MVP exposes no subagent steering operation or current-turn controller state. +- 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. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. - Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. -- The MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. +- This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. @@ -207,12 +207,12 @@ The implementation pins these behaviors: Removing Tasks gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. -Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but the MVP adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. +Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but this version adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. 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 into the MVP. +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. 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. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index eb14ebcec9..71089cd71a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -4,13 +4,13 @@ Status: implemented [English](2026-07-28-continuable-subagent-conversations.md) | 中文 -本提案将取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。提案保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 +本记录取代[可继续的后台 subagent](../../implemented/feature/2026-07-21-continuable-background-subagents.md)中由 Task 支撑的继续执行管理器。它保留[将 subagent 控制合并到 subagent 服务](../../implemented/simplification/2026-07-26-merge-subagent-control-service.md)确立的单一 `ctx.subagents` 服务,以及[以意图命名的 subagent 继续执行操作](../../implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md)确立的 `followup` 操作。 ## 问题 -继续执行管理器目前让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 +以前的继续执行管理器让一个 Task、一次提供方执行和一个结果边界共享同一生命周期。Task 结算会 dispose(资源释放)child Agent,Task 完成会注入完成通知,后续输入则重建另一个 Agent。这曾使通用后台工作抽象与会话投递耦合,而可继续 subagent 已经具备会话和 Agent inbox。 -如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。如果两种消息都交给 Task,系统又会重复 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 +如果继续执行管理器为 parent 请求排队,而 Agent 接收用户消息,系统就会出现两个 FIFO,且没有唯一的顺序权威。而把两种消息都交给 Task,则重复了 agent loop(智能体循环)已有的准入、取消和完全停稳机制。`Agent.whenIdle()` 无法恢复单项请求的 Task 结果,因为一个运行区间可能清空多个排队轮次;宽泛的 `Agent.cancel()` 也不能精确移除一项排队请求。 运行时生命周期也比单个轮次更长。subagent 可能已经结束自身轮次,但它创建的 child 仍在运行。此时 dispose parent 运行时,会移除仍负责后代拆卸的 Agent。反之,如果让所有历史 subagent 始终驻留,内存使用就会失去上界。 @@ -30,7 +30,7 @@ persisted Session 激活是重建 child Agent 的一次驻留周期。它可以执行多个 FIFO 轮次,并在等待后代时保持驻留。它不是请求、结果、取消或 Task 边界。 -继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。本提案不会为可继续 subagent 创建 Task、激活 FIFO 或 queued 激活状态。 +继续执行管理器负责激活准入、权限检查、在线所有权图、冷恢复和 child-first dispose。Agent loop 负责全部轮次排序与执行。没有任何可继续 subagent 拥有 Task、激活 FIFO 或 queued 激活状态。 ### 物化与公开操作 @@ -54,7 +54,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。经用户授权的冷恢复不会加载历史 parent Agent。parent 发起的恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。 -激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在 MVP 范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 +激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。 ### 激活生命周期 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 用户冷恢复会创建一次激活,但不会将其加入历史 parent 的 `ownedChildren`。如果直接 parent 随后向这个在线激活提交工作,且该 parent 自身由继续执行管理器管理,准入过程会在消息入队前建立所有权;非继续执行 parent 仍位于等待图之外。 -MVP 会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 +系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain,并涵盖由用户恢复且没有在线 owner 的激活。 @@ -115,13 +115,13 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect ### 延后的报告投递 -MVP 不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 +本版本不暴露 `report` 工具,也不提供从 child 到 parent 的内容投递或自动唤醒 parent。持久化 child 会话仍是 child 详细输出的来源。 后续提案可以增加一个普通的面向模型 `report(output)` 工具;模型在一个轮次中可以调用它零次或多次。其投递策略可以区分静默注入 parent 与唤醒 parent;接收方选择、确认、持久性和重试语义均与该工具一并延后决定。增加报告投递无需引入另一个激活状态或执行队列。 ### 延后的 steering(中途引导) -MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 +本版本不暴露 subagent steering 操作。parent 和用户的继续执行消息始终开启后续 FIFO 轮次,因此继续执行层不存储当前轮次控制方,也不新增能够感知控制方的 Agent 准入 seam。 后续宿主 UI 可以分别暴露 **Steer** 和 **Follow up** 操作。用户 steering 必须严格且仅限在线使用:只有当激活接受下一步骤时,它才能调用现有的 Agent steering 路径;其他情况必须拒绝,而且绝不能转为排队或冷恢复。是否通过面向模型的工具暴露 parent steering 仍需单独设计,因为不同的工具名称可以表达意图,却不能确定 parent 是否可以修改由用户控制的轮次。 @@ -129,13 +129,13 @@ MVP 不暴露 subagent steering 操作。parent 和用户的继续执行消息 权限来自可信宿主交互或确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 -MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 +本版本授权宿主用户和持久化 child 的直接 parent。系统会根据经过身份认证的 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、团队和工作流仍被拒绝,直至系统具备显式权限协议。 用户权限可以在 parent 不在线时冷恢复 child。由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 ### 持久性、dispose 与恢复 -没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本 MVP 不通过 subagent 服务暴露它。 +没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 和用户都不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 @@ -147,9 +147,9 @@ MVP 授权宿主用户和持久化 child 的直接 parent。系统会根据经 ### 范围 -MVP 覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 +本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。 -MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 +它不新增 subagent steering 操作、报告工具、从 child 到 parent 的内容投递、自动唤醒 parent、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。 ## 曾考虑的替代方案 @@ -159,9 +159,9 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **等待期间 dispose Agent。** child 仍属于上一个进程内所有权图时重建 parent,需要持久化所有权与拆卸协议。只为尚未完成的所有权图保留 `AgentHandle`,可以在不让已结算历史驻留的前提下,保留 child-first 拆卸。 -**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有 MVP 行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 +**让提供方通过 Agent handle 创建、恢复 child 或投递消息。** 初始提供方只持有 `prepareContinuable()` 及其分离式创建规格这一项差异:child 是全新启动,还是带有 parent 前缀。管理器必须通过私有 activation-owner 作用域自行调用 `ctx.agents.create()`,使该作用域成为每个 handle 的结构化所有者。持久化的进程内会话已经包含初始前缀及通用重建描述符,消息投递则属于 Agent inbox。让提供方持有任何后续 handle、`SubagentRun` 或消息所有权,会保留一条没有已发布行为可承载的 seam,还会因不必要的在线 parent 输入使用户冷恢复更加复杂。 -**在 MVP 中增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 +**现在就增加报告投递。** 可重复调用的面向模型工具与该生命周期兼容,但静默投递还是唤醒投递、接收方选择、确认、持久性和重试行为都是独立的产品决策。延后该工具,可以让首个版本专注于会话准入与驻留,又不限制后续策略。 **将 `SessionHeader.parentSession` 视为在线所有权。** 持久化谱系不能证明历史 parent 当前持有 child。在线 parent 的 `ownedChildren` 成员关系会记录进程内关系,而不改变持久化来源。 @@ -169,7 +169,7 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 **为 parent 消息维护单独队列。** 第二个 FIFO 会让它和 Agent 已接受的用户消息之间顺序不明确。单个 Agent inbox 为两种来源提供唯一且可观察的顺序。 -**在 MVP 中暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 +**现在就暴露 subagent steering。** 用户 steering 可以是严格且仅限在线使用的宿主操作,但 parent steering 需要当前轮次控制方状态,以保护由用户控制的轮次。首个版本将每条继续执行消息都排队,可以避免引入该状态及其准入竞争。后续 UI 可以新增一项仅限用户的独立操作,而不改变 follow-up 排序。 **返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。 @@ -189,14 +189,14 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 - `followup()` 只接受可信 parent 或用户权限;持久化消息来源信息不能授权投递。 - Parent 和用户的继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括一种来源排在另一种来源之后,以及 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 -- MVP 不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 -- MVP 不暴露 subagent steering 操作或当前轮次控制方状态。 +- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 - 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 -- MVP 不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 +- 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 @@ -207,12 +207,12 @@ MVP 不新增 subagent steering 操作、报告工具、从 child 到 parent 的 移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 -在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但 MVP 不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 +在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但本版本不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 没有报告投递时,完成 child 轮次既不会把内容发送给历史 parent,也不会唤醒它。输出会保留在持久化 child 会话中,直至调用方检查该 transcript 或提交另一个经过授权的轮次。后续报告工具可以增加静默投递或唤醒投递,而无需改变激活生命周期。 -将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在 MVP 中引入 parent 与用户之间的控制方策略。 +将每条继续执行消息排队,意味着 parent 无法立即纠正正在进行的 child 轮次;纠正操作会在下一个轮次执行。后续仅限用户的 UI steering 操作可以缩短该延迟,而无需在此引入 parent 与用户之间的控制方策略。 最终持久性检查点失败时,运行时所有权图仍可完成 drain,但持久化 child 状态会缺失或陈旧。该失败会以 `DURABILITY_FAILED` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4d08e8221a..19302ab38e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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:141`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:143`](../../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:115`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:117`](../../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:121`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:123`](../../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:132`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6e8ab17396..d8c55b94d1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,6 +1980,15 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +/** + * 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. @@ -2033,7 +2042,7 @@ async start(name: string, request: SubagentStartRequest): Promise 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) -Source: [`packages/subagent/subagent/src/index.ts:174`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:176`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d655798990..3a8cd50e70 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -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: a58ecf13ba1f5df0e8e35c793eaf9aefc1e8a900 -subagent.zh.md: 541eace7fc6c8ae10ee22639680918e12d7762b3 +subagent.md: ceff3586bf6724bd6f47b71e9fb737361a2830f8 +subagent.zh.md: aa39ea382fe1e2a52b6ee794cfa71d8abc945da3 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index a58ecf13ba..ceff3586bf 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -123,7 +123,7 @@ persisted Session 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. -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`; only a trusted host adapter can supply user authority. `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. +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. 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. @@ -149,8 +149,14 @@ interface CoordinatorMessageSource { 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. */ - | { readonly kind: 'user' } + /** + * 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 diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 541eace7fc..aa39ea382f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -149,8 +149,14 @@ interface CoordinatorMessageSource { 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. */ - | { readonly kind: 'user' } + /** + * 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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d41a6b329b..ce2e06088a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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:141`](../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:115`](../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:121`](../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:132`](../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: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) | | `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`) | - | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f3a3f87e3a..5412001611 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1151,7 +1151,7 @@ The registered tool name is the load-time `toolName` config (default `subagent`) ### `send_message` -Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. +Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index b1c44a10ea..25aa600858 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -110,7 +110,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -122,22 +122,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 7863590b88..0ab66be027 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -239,7 +239,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -276,7 +276,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -290,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -301,7 +301,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -315,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 585c601a84..6f0dfc2aab 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -182,7 +182,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -219,7 +219,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -233,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -244,7 +244,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -258,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index e53e20d3ad..7cb77234c2 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -93,7 +93,7 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered. */ + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered. */ send_message: { /** The subagent id returned when the background subagent was started. */ subagent_id: string; @@ -105,22 +105,22 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message. */ + /** Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index dd4be5f915..6248d0449f 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -198,7 +198,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -235,7 +235,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -249,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -260,7 +260,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -274,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d80fe4b555..539c0514fd 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index 59bb91d9ac..476cb70ceb 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -402,7 +402,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -416,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -427,7 +427,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -441,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index ee1c0e158b..c6c3c9ee92 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index c94b51630d..f61d20859d 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -161,7 +161,7 @@ }, { "name": "send_message", - "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its transcript by its id to see what it did. A failure means the message was NOT delivered.", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use this only to give it more work. A failure means the message was NOT delivered.", "parameters": { "type": "object", "properties": { @@ -198,7 +198,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -212,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ @@ -223,7 +223,7 @@ }, { "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back to you, so read its transcript by that id, or send it more work with `send_message`.", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive its subagent id and it works on its own. It does not report back, so use this only for work whose result you do not need returned; `send_message` sends it more work.", "parameters": { "type": "object", "properties": { @@ -237,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background subagent that keeps its conversation and return its subagent id; send it more work with send_message." + "description": "Run as a background subagent that keeps its conversation and return its subagent id. It does not report its result back; send it more work with send_message." } }, "required": [ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0cf46786e3..210024259f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,6 +892,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( authority: SubagentAuthority, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', 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 */', @@ -2693,7 +2697,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentAuthority', - declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n};', + declaration: 'export type SubagentAuthority = {\n readonly kind: \'parent\';\n readonly agent: Agent;\n} | {\n readonly kind: \'user\';\n readonly grant: UserAuthorityGrant;\n};', }, { name: 'SubagentCapabilities', @@ -3035,6 +3039,10 @@ 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;\n}', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index c906868e1d..9ddbf4f465 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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: fc1eecb7d22c45377d5525ef0247bcf369a441a8 -README.zh.md: 762a027324bc40f159129c3cd4a438d2265fa32b +README.md: 6a8016dc71d928c1770cc0769f99d2cb53c6b035 +README.zh.md: 53f553bd2747bebac0f2d42ac80ad8b6eb660c45 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index fc1eecb7d2..6a8016dc71 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,12 +31,13 @@ Multiple providers may coexist under different names. This lets a deployment exp | `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. | | `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' }`. 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. +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. 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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 762a027324..53f553bd27 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,12 +31,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `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`。 | | `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' }`。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 +可继续操作的权限来自可信的 host 交互或准确的实时 Agent 工具上下文:`SubagentAuthority` 为 `{ kind: 'parent', agent }` 或 `{ kind: 'user', grant }`——其 grant 仅由 `userAuthority()` 铸造,因此仅凭判别式无法声明该权限。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。父级权限要求准确匹配子 agent 持久化 header 中记录的实时直接父级;用户权限可以继续任何子 agent,并且可以在不加载其历史父级的情况下将其冷恢复。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 9974a355a4..632c546bd8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -61,8 +61,20 @@ declare module '@deepseek-ai/dsh-llm' { 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. */ - | { readonly kind: 'user' } + /** + * 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 { @@ -245,6 +257,8 @@ 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 @@ -325,6 +339,10 @@ export class SubagentContinuationManager { composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) + // Materialization published the Activation; an abort landing in that + // window — a `subagent/start` listener can cancel synchronously — must + // roll the child back instead of opening its first turn. + await this.rollbackIfAborted(activation, spec.signal) return this.submit(activation, request.prompt, { kind: 'user' }, { kind: 'parent', agent: parent }) }) return { childId, messageId } @@ -494,9 +512,25 @@ export class SubagentContinuationManager { composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, }) + await this.rollbackIfAborted(activation, options.signal) return this.submit(activation, content, options.source, authority) } + /** + * Dispose a freshly materialized Activation when the caller signal won the + * handoff between publication and inbox acceptance, so an aborted operation + * never leaves a resident child. + * @param activation - the just-published Activation. + * @param signal - the caller signal owning admission until acceptance. + */ + private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise { + if (!signal.aborted) return + /* v8 ignore next -- the swallow only covers a disposal fault during rollback, which + * must not mask the caller's abort as the operation's failure. */ + await this.dispose(activation).catch(() => undefined) + signal.throwIfAborted() + } + /** * Create or resume the child Agent through the private activation-owner * scope, install the handle in a fresh Activation, and register ownership on @@ -686,7 +720,17 @@ export class SubagentContinuationManager { childId: SessionId, parentSession: SessionId | undefined, ): void { - if (authority.kind === 'user') return + 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( diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index a5e120c860..6cc8c0fbe7 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -54,6 +54,7 @@ import SubagentContinuationManager from './continuation.ts' import type { ActivationObserver, ActivationState, + UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, SubagentAuthority, @@ -94,6 +95,7 @@ export type { ChildComposition } from './child-agent.ts' export type { ActivationObserver, ActivationState, + UserAuthorityGrant, ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, @@ -174,6 +176,15 @@ export interface SubagentRunEndInfo { export class SubagentService extends Service { private providers = new Map() 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. + */ + private readonly userGrant = Object.freeze({ + __brand: 'SubagentUserAuthority', + }) as UserAuthorityGrant constructor(ctx: Context) { super(ctx, 'subagents') @@ -181,7 +192,7 @@ export class SubagentService extends Service { 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. */ @@ -227,6 +238,17 @@ export class SubagentService extends Service { 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. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index c20cc175a9..47373046f3 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -213,6 +213,22 @@ describe('SubagentService.startContinuable', () => { }) }) + it('rolls the child back when the signal aborts between publication and acceptance', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const controller = new AbortController() + // `subagent/start` fires once the epoch is resident, before the prompt is + // submitted, so cancelling here lands squarely in the handoff window. + ctx.on('subagent/start', () => { controller.abort('caller gave up') }) + + await expect(ctx.subagents.startContinuable(startSpec(parent, 'spawn', controller.signal))) + .rejects.toThrow() + + // No resident child and no queued turn survive the abort. + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + }) + it('rejects a continuable child that would exceed the configured depth cap', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.startContinuable({ @@ -287,7 +303,7 @@ describe('SubagentService.startContinuable', () => { await fresh.plugin(AgentLoop, { agents: [] }) await fresh.plugin(SubagentService) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - await followup(fresh, { kind: 'user' }, started.childId, message('resume routeless')) + await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { const found = fresh.agents.get(started.childId) @@ -337,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, { kind: 'user' }, started.childId, message('resume it')) + await followup(ctx, ctx.subagents.userAuthority(), 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) @@ -360,7 +376,7 @@ describe('SubagentService.followup residency routing', () => { // 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, { kind: 'user' }, started.childId, message('from user')) + const userMessage = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('from user')) expect(parentMessage).not.toBe(userMessage) // Still the same Activation: no second child Agent was created. expect(ctx.agents.get(started.childId)).toBe(child) @@ -376,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, { kind: 'user' }, started.childId, message('continue please')) + const messageId = await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('continue please')) expect(messageId).toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -410,7 +426,7 @@ describe('SubagentService.followup residency routing', () => { // Waiting retains the handle: the same Agent is still live. expect(ctx.agents.get(started.childId)).toBe(child) - await followup(ctx, { kind: 'user' }, started.childId, message('while waiting')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('while waiting')) // Woken back to running on the SAME Activation. expect(ctx.agents.get(started.childId)).toBe(child) @@ -421,6 +437,23 @@ 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)) @@ -447,7 +480,7 @@ describe('SubagentService.followup residency routing', () => { fresh.llm.registerAdapter(['mock'], new MockAdapter([textResponse('resumed cold')])) expect(fresh.agents.get(SessionId('parent'))).toBeUndefined() - await followup(fresh, { kind: 'user' }, started.childId, message('user continues')) + await followup(fresh, fresh.subagents.userAuthority(), started.childId, message('user continues')) await waitNoActivation(fresh, started.childId) const loaded = await fresh.sessionPersistence.load(started.childId) @@ -469,13 +502,13 @@ describe('SubagentService.followup residency routing', () => { const oneShotId = run.id await run.dispose() - await expect(followup(ctx, { kind: 'user' }, oneShotId, message('continue'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), 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, { kind: 'user' }, SessionId('missing'), message('hello'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), SessionId('missing'), message('hello'))) .rejects.toMatchObject({ code: 'NOT_RESUMABLE' }) }) @@ -491,7 +524,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, { kind: 'user' }, started.childId, message('raced'))) + followup(ctx, ctx.subagents.userAuthority(), started.childId, message('raced'))) await expect(delivery).resolves.toBeTypeOf('string') await waitNoActivation(ctx, started.childId) @@ -619,7 +652,7 @@ describe('continuable durability and teardown', () => { await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) - await expect(followup(ctx, { kind: 'user' }, started.childId, message('too late'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('too late'))) .rejects.toMatchObject({ code: 'DRAINING' }) }) @@ -630,7 +663,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, { kind: 'user' }, started.childId, message('never logged')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('never logged')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -674,7 +707,7 @@ describe('continuable review regressions', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, { kind: 'user' }, started.childId, message('cancelled'), controller.signal)) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('cancelled'), controller.signal)) .rejects.toThrow() // Nothing was enqueued, so no later turn can carry it. @@ -699,7 +732,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, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), 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' }]) @@ -717,7 +750,7 @@ describe('continuable review regressions', () => { if (subject === parent) return next() return { kind: 'block', reason: 'blocked by policy' } }) - await followup(ctx, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -786,7 +819,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, { kind: 'user' }, started.childId, message('discarded')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('discarded')) const drained = ctx.subagents.drainContinuable() hold.resolve(undefined) @@ -797,6 +830,32 @@ describe('continuable review regressions', () => { expect(hasUserText(loaded.events, 'discarded')).toBe(false) }) + it('settles after a delivery discarded inside its own admission window', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + + // Cancel from the synchronous enqueue observer: the discard fires before + // `followup()` returns, so the id is discarded before it can be recorded. + const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + child.cancel({ kind: 'user' }) + } + }) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('doomed')) + off() + + releaseFirst.resolve(undefined) + // Retaining the discarded id would pin residency at `running` forever, so + // reaching no-Activation without an explicit drain is the assertion. + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'doomed')).toBe(false) + }) + it('reports completed when no ordinary turn closed', async () => { const { ctx, parent } = await setup([]) const ends: SubagentRunEndInfo[] = [] @@ -832,7 +891,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - await followup(ctx, { kind: 'user' }, started.childId, message('queued')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('queued')) expect(states.length).toBeGreaterThan(0) expect(states).not.toContain('settled') @@ -854,7 +913,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, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await waitNoActivation(ctx, started.childId) await vi.waitFor(() => { expect(ends).toHaveLength(2) }) @@ -898,7 +957,7 @@ describe('continuable public surface', () => { const controller = new AbortController() controller.abort('caller gave up') - await expect(followup(ctx, { kind: 'user' }, started.childId, message('aborted'), controller.signal)) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('aborted'), controller.signal)) .rejects.toThrow() const loaded = await ctx.sessionPersistence.load(started.childId) @@ -916,7 +975,7 @@ describe('continuable public surface', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const controller = new AbortController() - await followup(ctx, { kind: 'user' }, started.childId, message('survives'), controller.signal) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('survives'), controller.signal) // After acceptance the manager owns the Activation independently. controller.abort('caller gave up') @@ -945,7 +1004,7 @@ describe('continuable errors', () => { }).continuations manager.activations.delete(started.childId) - await expect(followup(ctx, { kind: 'user' }, started.childId, message('hello'))) + await expect(followup(ctx, ctx.subagents.userAuthority(), started.childId, message('hello'))) .rejects.toThrow(SubagentError) expect(ctx.agents.get(started.childId)).toBe(child) hold.resolve(undefined) @@ -1068,7 +1127,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, { kind: 'user' }, started.childId, message('again')) + await followup(ctx, ctx.subagents.userAuthority(), started.childId, message('again')) await vi.waitFor(() => { expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 2260aaf119..78d45eea47 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -133,7 +133,7 @@ describe('SubagentService', () => { signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' }) await expect(subagents.followup( - { kind: 'user' }, + subagents.userAuthority(), SessionId('child'), [{ type: 'text', text: 'hello' }], { source: { kind: 'user' }, signal: new AbortController().signal }, diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 1fbbc3d3fb..e8d2b1d4a1 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -26,8 +26,8 @@ export function apply(ctx: Context): void { description: 'Send a message to a background subagent by its subagent id, continuing the same conversation. It ' + 'becomes the subagent\'s next turn: if it is still working, the message waits until its current turn ' - + 'finishes, so it cannot redirect work already underway. The subagent does not reply to you — read its ' - + 'transcript by its id to see what it did. A failure means the message was NOT delivered.', + + 'finishes, so it cannot redirect work already underway. The subagent does not reply to you, so use ' + + 'this only to give it more work. A failure means the message was NOT delivered.', parameters: { subagent_id: { type: 'string', diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 62fef8c055..e163f2e891 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -207,8 +207,8 @@ export function apply(ctx: Context, config: Config): void { description: wording.description + (backgroundEnabled ? continuable ? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:' - + ' you receive its subagent id and it works on its own. It does not report back to you, so read' - + ' its transcript by that id, or send it more work with `send_message`.' + + ' you receive its subagent id and it works on its own. It does not report back, so use this' + + ' only for work whose result you do not need returned; `send_message` sends it more work.' : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -226,8 +226,8 @@ export function apply(ctx: Context, config: Config): void { run_in_background: { type: 'boolean' as const, description: continuable - ? 'Run as a background subagent that keeps its conversation and return its subagent id; ' - + 'send it more work with send_message.' + ? 'Run as a background subagent that keeps its conversation and return its subagent id. ' + + 'It does not report its result back; send it more work with send_message.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, From 853f4d5cfbd7dd2cb54626556f8f6c80fae37e18 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 17:47:48 +0800 Subject: [PATCH 19/66] refactor(subagent): drop host-user authority and split lifecycle publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 57 ++-- ...8-continuable-subagent-conversations.zh.md | 57 ++-- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 33 +-- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 49 +--- docs/core-data-structures/subagent.zh.md | 49 +--- docs/event-producer-consumer.md | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 24 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 17 +- packages/subagent/subagent/README.zh.md | 17 +- .../subagent/subagent/src/continuation.ts | 140 +++------- packages/subagent/subagent/src/index.ts | 256 ++---------------- packages/subagent/subagent/src/invariant.ts | 3 +- packages/subagent/subagent/src/lifecycle.ts | 244 +++++++++++++++++ packages/subagent/subagent/src/types.ts | 42 ++- .../subagent/tests/continuation.spec.ts | 151 +++++------ .../subagent/subagent/tests/service.spec.ts | 2 +- .../tool-subagent-control/package.json | 2 - .../tool-subagent-control/src/index.ts | 2 +- pnpm-lock.yaml | 6 - scripts/gen-cordis-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 10 - 25 files changed, 524 insertions(+), 671 deletions(-) create mode 100644 packages/subagent/subagent/src/lifecycle.ts diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 7e2a36b502..70a926d303 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index a56da8ad38..df2aaa71dd 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -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`. 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. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 71089cd71a..4437e73a3f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -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` 的形式被观测到;重试与修复需要单独的恢复设计。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 19302ab38e..28eea8cb5d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d8c55b94d1..fd51125a06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1967,35 +1967,18 @@ async startContinuable(spec: ContinuableStartSpec): Promise * 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 - -/** - * 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 /** * Close continuable admission synchronously, then dispose every live @@ -2040,9 +2023,9 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -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) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 3a8cd50e70..6a66f6d498 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -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 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ceff3586bf..81d09903be 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -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 diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index aa39ea382f..6fd6845b5c 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce2e06088a..7cccf7ab63 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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`) | - | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 210024259f..59e901b2fe 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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', - 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', + 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', @@ -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;\n}', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 9ddbf4f465..23bc8500fc 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6a8016dc71..1b38d493ef 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 53f553bd27..ec907f4667 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -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,但丢失的消息不会自动重放。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 632c546bd8..072e286b36 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -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 } 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 { + private async authorizeLive(parent: Agent, activation: Activation): Promise { 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`, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 6cc8c0fbe7..5d66ff8da5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -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() 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 { - 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 '' - } -} - export default SubagentService diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index 3c350c13a1..dd224b68de 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -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' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts new file mode 100644 index 0000000000..5d809a7edb --- /dev/null +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -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 '' + } +} diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 3ff7368b37..5c703c6ce5 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -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 diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 47373046f3..02dd7896ca 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -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[0] @@ -109,12 +109,12 @@ function userTexts(events: readonly SessionEvent[]): string[] { function followup( ctx: Context, - authority: SubagentAuthority, + parent: Agent, childId: SessionId, content: ReturnType, 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 { 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() 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 = ctx.subagents as unknown as Record - 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') }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 78d45eea47..64c510bf40 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -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 }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 4fdd483ff7..e5330f43aa 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -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" } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index e8d2b1d4a1..db022d1c35 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -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, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45790d4591..69b16ee2e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index faaafc4c11..643af86aa6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,13 +160,11 @@ export const LINK_MAP: Readonly> = { 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> = { 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', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e9b60aa650..9154fca93a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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", From a91b20f6beacd4167d1bdc649e44e47eb86c712c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 21:21:14 +0800 Subject: [PATCH 20/66] fix(subagent): close continuation lifecycle gaps --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +- ...8-continuable-subagent-conversations.zh.md | 12 +- ...subagent-continuation-operations.i18n.yaml | 4 +- ...-named-subagent-continuation-operations.md | 2 +- ...med-subagent-continuation-operations.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 2 +- docs/config-catalog.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 18 +- .../hooks-claude/tests/coverage-cases.ts | 18 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/continuation.ts | 204 +++++++++++------- packages/subagent/subagent/src/index.ts | 2 +- packages/subagent/subagent/src/invariant.ts | 8 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/tests/continuation.spec.ts | 159 +++++++++++++- .../subagent/subagent/tests/invariant.spec.ts | 16 +- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- scripts/gen-doc-graphs.ts | 2 +- 29 files changed, 365 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 70a926d303..879a8ecf22 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: df2aaa71dde4980bf2dd533c11254d0db8fe61b3 -2026-07-28-continuable-subagent-conversations.zh.md: 4437e73a3fa2f4d2043d2cfffe71259754fddeef +2026-07-28-continuable-subagent-conversations.md: e0119975d5f815886d671959efdc3028a5929f46 +2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index df2aaa71dd..e0119975d5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -36,11 +36,11 @@ The continuation manager owns activation admission, authority checks, the live o The named subagent provider participates only in preparing the initial creation spec, where `spawn` and `fork` differ. Its optional `prepareContinuable(request): Promise` method is the continuable-creation capability. The returned spec contains only detached provider-specific creation inputs such as the optional parent-history seed; it contains no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation. The manager reserves the child identity, resolves the durable descriptor and common Agent setup, calls `ctx.agents.create()` through a private activation-owner scope, installs the returned `AgentHandle` into the Activation, establishes any continuable-parent ownership, and then calls `Agent.followup(initialPrompt)`. Inbox acceptance yields an `MessageId`; at that boundary `ctx.subagents.startContinuable()` returns `{ childId, messageId }` without waiting for the turn to start or for the message to enter the Session log. -Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. +Any failure before inbox acceptance rejects without returning either id. Agent creation provides rollback before handle transfer; after transfer, the manager keeps one closing transaction visible to concurrent delivery and drain, disposes the created handle, removes the Activation, and rolls back any parent `ownedChildren` membership before rejecting. Failure before the residency start edge publishes no terminal edge, while failure after a published start closes the lifecycle pair through normal disposal. `backgroundMode: 'one-shot' | 'continuable'` remains deployment policy. Configured continuable mode requires `prepareContinuable`; method presence replaces `SubagentProvider.resume?()` as the capability check, while a capable provider may still run one-shot work. -Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent, and the initial provider name is not a recovery capability; remote providers require a separate design. +Cold resume does not dispatch through a subagent provider. The continuation manager folds the generic in-process descriptor, calls `ctx.agents.resume()` through the same activation-owner scope, installs the returned `AgentHandle`, and submits the waiting `next-turn`. `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. The initial provider name remains lifecycle provenance after that provider unregisters; it is not a recovery capability or a requirement for later residency. Remote providers require a separate design. `SubagentProvider.start()` and `SubagentRun` remain exclusively on the unchanged one-shot path. A continuable Activation directly owns its `AgentHandle` and never creates, wraps, or retains a `SubagentRun`; `SubagentRun.steer?()` is therefore absent. @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. @@ -182,8 +182,8 @@ The implementation pins these behaviors: - A continuable child has at most one live Activation and one Agent inbox; the continuation manager has no Activation FIFO or queued Activation state. - `SubagentProvider.prepareContinuable?()` returns only a detached `ContinuableCreateSpec`; configured continuable mode requires that capability, while `backgroundMode` remains an independent policy choice. - The manager calls `ctx.agents.create()` through its private activation-owner scope, installs the returned `AgentHandle` and parent ownership, calls `Agent.followup(initialPrompt)`, and returns `{ childId, messageId }` when inbox acceptance yields the `MessageId`, without waiting for turn start or a Session-log write. -- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership. -- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through the initial subagent provider; `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. +- Every failure before initial-prompt inbox acceptance rejects without ids and rolls back any created handle, Activation, and parent `ownedChildren` membership through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge. +- Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. - `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery. - Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn. @@ -198,7 +198,7 @@ The implementation pins these behaviors: - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance failure, caller-signal ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 4437e73a3f..fdf34d6826 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -36,11 +36,11 @@ persisted Session 具名 subagent 提供方只参与准备初始创建规格,此时 `spawn` 与 `fork` 有所区别。其可选的 `prepareContinuable(request): Promise` 方法就是可继续创建能力。返回的规格只包含与 Agent 实例分离且由提供方决定的创建输入,例如可选的 parent 历史种子;它不包含 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作。管理器会预留 child 身份,解析持久化描述符和通用 Agent 配置,通过私有 activation-owner 作用域调用 `ctx.agents.create()`,将返回的 `AgentHandle` 安装到激活中,建立适用的可继续 parent 所有权,然后调用 `Agent.followup(initialPrompt)`。inbox 接受消息后会产生一个 `MessageId`;`ctx.subagents.startContinuable()` 在此边界返回 `{ childId, messageId }`,不等待轮次开始,也不等待消息写入会话日志。 -inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会先 dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。 +inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的情况下被拒绝。Agent 创建流程负责 handle 移交前的回滚;移交后,管理器会保留一个对并发投递和 drain 可见的关闭事务,dispose 已创建的 handle、移除激活并回滚 parent `ownedChildren` 中的任何成员关系,再拒绝操作。在驻留 start 事件发布前失败不会发布终止事件,start 发布后失败则通过正常 dispose 闭合生命周期配对。 `backgroundMode: 'one-shot' | 'continuable'` 仍是部署策略。配置为 continuable 时要求存在 `prepareContinuable`;该方法是否存在会取代 `SubagentProvider.resume?()` 成为能力检查,而具备该能力的提供方仍可运行 one-shot 工作。 -冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在,初始提供方名称也不是恢复能力;远程提供方需要单独设计。 +冷恢复不会通过 subagent 提供方分发。继续执行管理器会归并通用的进程内描述符,通过同一个 activation-owner 作用域调用 `ctx.agents.resume()`,安装返回的 `AgentHandle`,并提交等待中的 `next-turn`。`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。初始提供方注销后,其名称仍作为生命周期来源信息保留;它不是恢复能力,也不是后续驻留的必要条件。远程提供方需要单独设计。 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining 和激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 @@ -182,8 +182,8 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 可继续 child 至多拥有一个在线激活和一个 Agent inbox;继续执行管理器没有激活 FIFO 或 queued 激活状态。 - `SubagentProvider.prepareContinuable?()` 只返回分离式 `ContinuableCreateSpec`;配置为 continuable 时要求具备该能力,而 `backgroundMode` 仍是独立的策略选择。 - 管理器通过私有 activation-owner 作用域调用 `ctx.agents.create()`,安装返回的 `AgentHandle` 并建立 parent 所有权,调用 `Agent.followup(initialPrompt)`,然后在 inbox 接受消息并产生 `MessageId` 时返回 `{ childId, messageId }`,而不等待轮次开始或消息写入会话日志。 -- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系。 -- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过初始 subagent 提供方分发;`SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 +- 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件。 +- 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 - `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 @@ -198,7 +198,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前失败路径的完整回滚、接受前后两个阶段的调用方 signal 所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index a8b249fae0..ad2e2c40bd 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 9f29074add3517d0baf94516c56fa69085ef75c4 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: a748af1a6cf44bc552b492d43314bf5a4e95338d +2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 9f29074add..5029d8335f 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, its bare-`Agent` parameter with an explicit authority union, and provider `resume` dispatch with `prepareContinuable`. +The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index a748af1a6c..0785730c19 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,以显式的 authority(授权)联合类型替换裸 `Agent` 参数,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 ## 问题 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 2d14dedd68..576ef05d32 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330 -architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1 +architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d +architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f diff --git a/docs/architecture.md b/docs/architecture.md index 44be3b55ab..6aa942ba27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers and Activation-based continuations | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d803bea1ba..c8aaa68527 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -38,7 +38,7 @@ | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方和由 Activation 支撑的继续执行 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2681bd728a..561b59e10e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -391,7 +391,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9b7be60536..420b0f4b66 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -503,7 +503,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6a66f6d498..39393ba161 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: 81d09903bec3bd4767e73720be4a8d58c7837eb4 -subagent.zh.md: 6fd6845b5ceedd81e02365680a534e6d0c726aaf +subagent.md: e160c596acb55f0e94cba84b8c79355c966eb51a +subagent.zh.md: 6b934a523fa0ea5d53ea9a670e56b72b7f785593 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 81d09903be..e160c596ac 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -341,7 +341,7 @@ interface SubagentProvider { } ``` -Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +Provider `start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. Each continuable Activation emits the same observe-only pair for its residency epoch, so a cold resume is a new epoch with its own `runId`. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. Their `provider` field is provenance for the run or Activation epoch, not a claim that the provider remains registered when the edge is emitted. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 6fd6845b5c..6b934a523f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -343,7 +343,7 @@ interface SubagentProvider { } ``` -提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。 +提供方的 `start()` 仅在 run 就绪时 fulfill。服务铸造唯一的 `runId`,从提供方确切的 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。每个可继续 Activation 都会为其驻留纪元 emit 相同的仅观察事件对,因此一次冷恢复就是一段拥有自己 `runId` 的新纪元。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,且会隔离各自的 listener 异常。其中的 `provider` 字段是 run 或 Activation 时段的来源信息,并不声明该 edge 发出时提供方仍处于注册状态。 ## 进程内后端:深度与种子 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..39a4ce1dc3 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -31,10 +31,9 @@ import { type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event -// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the -// SubagentStart/SubagentStop listeners below type-check. -import type {} from '@deepseek-ai/dsh-subagent' +// Pulls in the declaration-merged subagent events and the identity pairing their +// start/end edges. +import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' export const name = 'hooks-claude' @@ -119,6 +118,10 @@ export function apply(ctx: Context, config: Config): void { // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before resolving. const detached = createDetachedRuns() + // Only the start edge guarantees registry access. Retain each local child + // through its paired end so stop hooks keep the session workspace after the + // handle unregisters the agent. + const subagentChildren = new Map() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') /** @@ -276,6 +279,7 @@ export function apply(ctx: Context, config: Config): void { // use the live child's workspace and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) + if (child !== undefined) subagentChildren.set(info.runId, child) detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -284,10 +288,8 @@ export function apply(ctx: Context, config: Config): void { .catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })) }) ctx.on('subagent/end', (info) => { - // Look up the child (still recoverable: `subagent/end` fires from the service's detached - // `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the - // child's cwd, not the server default. - const child = ctx.get('agents')?.get(info.id) + const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id) + subagentChildren.delete(info.runId) detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) } diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index f9f5d1b088..57abe9c101 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -681,12 +681,11 @@ export function defineCoverageCases(group: CoverageGroup): void { }) it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` - // receives that agent and runs in the child's cwd rather than the executor default. const serverDir = dir() const childDir = dir() const marker = join(childDir, 'stopwhere') - hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) + const payload = join(childDir, 'stoppayload') + hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) @@ -698,15 +697,22 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) + const runId = SubagentRunId('run-stop') + const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true } + // Start is the registry-backed capture edge; end deliberately follows + // handle disposal, matching continuable Activation settlement. + ctx.emit(subagentCarrier(ctx), 'subagent/start', identity) + await childHandle.dispose() + expect(ctx.agents.get(childHandle.agent.id)).toBeUndefined() + ctx.emit(subagentCarrier(ctx), 'subagent/end', { ...identity, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() + const input = JSON.parse(readFileSync(payload, 'utf8')) as { cwd: string; session_id: string } // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) - await childHandle.dispose() + expect(input).toMatchObject({ cwd: childDir, session_id: childHandle.agent.id }) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 23bc8500fc..b8ed113445 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 1b38d493efa1dbe86464ad376649ff37914067da -README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49 +README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457 +README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 1b38d493ef..0e59a1ad5f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -80,7 +80,7 @@ A continuation-managed parent Activation records each child Session id in an `ow ## Lifecycle events -The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. +The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index ec907f4667..073b490352 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -80,7 +80,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 生命周期事件 -服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 072e286b36..109c5b666f 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -36,6 +36,7 @@ import { resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' +import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' import type { ActivationObserver } from './lifecycle.ts' @@ -248,6 +249,7 @@ export class SubagentContinuationManager { this.requirePersistence() const request = spec.request const parent = request.parent + assertSubagentMaxDepth(request.maxDepth) const childId = SessionId(randomUUID()) const childDepth = resolveChildDepth(parent, request.maxDepth) // Snapshot before any await: invalid descriptor JSON rejects the call @@ -282,11 +284,13 @@ export class SubagentContinuationManager { composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) - // Materialization published the Activation; an abort landing in that - // window — a `subagent/start` listener can cancel synchronously — must - // roll the child back instead of opening its first turn. - await this.rollbackIfAborted(activation, spec.signal) - return this.submit(activation, request.prompt, { kind: 'user' }, parent) + return this.submitMaterialized( + activation, + request.prompt, + { kind: 'user' }, + parent, + spec.signal, + ) }) return { childId, messageId } } @@ -328,13 +332,8 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - await this.authorizeLive(parent, activation) - // The caller signal owns admission until acceptance, so re-check it - // here: the outer check cannot cover an abort that landed while - // authorization yielded, and enqueueing afterwards would return a - // message id for a delivery the caller already cancelled. - options.signal.throwIfAborted() - return this.submit(activation, content, options.source, parent) + this.authorizeLive(parent, activation) + return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that * race reaches the retry below, which then cold-resumes a new Activation. */ @@ -455,23 +454,33 @@ export class SubagentContinuationManager { composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, }) - await this.rollbackIfAborted(activation, options.signal) - return this.submit(activation, content, options.source, parent) + return this.submitMaterialized(activation, content, options.source, parent, options.signal) } /** - * Dispose a freshly materialized Activation when the caller signal won the - * handoff between publication and inbox acceptance, so an aborted operation - * never leaves a resident child. - * @param activation - the just-published Activation. - * @param signal - the caller signal owning admission until acceptance. + * Submit to a freshly materialized Activation or roll it back completely. + * @param activation - the just-published Activation to admit or release. + * @param content - the initial or resumed message content. + * @param source - durable provenance for the accepted message. + * @param parent - the live direct parent authorizing admission. + * @param signal - caller cancellation owning admission until acceptance. + * @returns the accepted inbox message id. */ - private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise { - if (!signal.aborted) return - /* v8 ignore next -- the swallow only covers a disposal fault during rollback, which - * must not mask the caller's abort as the operation's failure. */ - await this.dispose(activation).catch(() => undefined) - signal.throwIfAborted() + private async submitMaterialized( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + parent: Agent, + signal: AbortSignal, + ): Promise { + try { + return this.submitAdmitted(activation, content, source, parent, signal) + } catch (error: unknown) { + /* v8 ignore next -- rollback disposal failures must not mask the + * pre-acceptance signal, drain, or lifecycle failure. */ + await this.dispose(activation).catch(() => undefined) + throw error + } } /** @@ -498,30 +507,24 @@ export class SubagentContinuationManager { inputs.signal.throwIfAborted() const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) } const observer = this.host.observeActivation(provider, childId, parent) - let handle: AgentHandle - try { - const { create } = inputs - handle = create === undefined - ? await this.ownerCtx.agents.resume({ - resumeSessionId: childId, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) - : await this.ownerCtx.agents.create({ - sessionId: childId, - meta: create.meta, - seed: create.seed, - agentOptions: inputs.agentOptions, - signal: inputs.signal, - setup, - }) - } catch (error: unknown) { - // Agent creation provides rollback before handle transfer, so nothing - // outlives this rejection; report the epoch that never became resident. - // No start edge was published, so this epoch has no lifecycle to close. - throw error - } + const { create } = inputs + // Agent creation owns rollback before handle transfer. A rejection leaves + // no resident Activation and therefore publishes no lifecycle edge. + const handle: AgentHandle = create === undefined + ? await this.ownerCtx.agents.resume({ + resumeSessionId: childId, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) + : await this.ownerCtx.agents.create({ + sessionId: childId, + meta: create.meta, + seed: create.seed, + agentOptions: inputs.agentOptions, + signal: inputs.signal, + setup, + }) const activation: Activation = { childId, @@ -540,42 +543,53 @@ export class SubagentContinuationManager { inputs.signal.throwIfAborted() this.assertAdmitting() this.acquireOwnership(parent, childId) + // Every accepted id leaves the inbox exactly once, through dequeue or + // discard. Clearing it there is what lets `stateOf()` distinguish a truly + // quiet Agent from one whose accepted turn has not been admitted yet. + // Registered through the child's own scoped context, so scope filtering + // already restricts both listeners to this exact agent. + handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { + /* v8 ignore next -- a dequeue of an id this manager never admitted needs + * another sender on the same child, which no current path allows. */ + if (activation.accepted.delete(item.message.id)) this.wake(activation) + }) + handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { + // Deleting every id in the batch is unconditional; waking once afterwards + // costs nothing and avoids branching on which ids this manager admitted. + for (const item of items) activation.accepted.delete(item.message.id) + this.wake(activation) + }) + // Resident: publish the start edge before any turn can run, so observers + // see this epoch before its first request. + observer.start(handle.agent) } catch (error: unknown) { - // Roll the transfer back completely: the Activation leaves the map, the - // parent's ownership membership is released, and the created handle is - // disposed before this rejection surfaces. No lifecycle edge is published, - // because `observer.start()` below has not run for this epoch. - this.activations.delete(childId) - this.releaseOwnership(childId) - activation.disposal = handle.dispose() - /* v8 ignore next -- the created handle disposes cleanly on every rollback this - * transaction can reach; the catch only keeps a disposal fault from masking `error`. */ - await activation.disposal.catch(() => undefined) + // Listener exceptions are contained by the lifecycle emitter; a start + // publication throw therefore leaves no residency edge to pair. + /* v8 ignore next -- rollback failure must not mask the admission failure + * that prevented this operation from returning an accepted message id. */ + await this.rollbackUnpublished(activation).catch(() => undefined) throw error } - // Every accepted id leaves the inbox exactly once, through dequeue or - // discard. Clearing it there is what lets `stateOf()` distinguish a truly - // quiet Agent from one whose accepted turn has not been admitted yet. - // Registered through the child's own scoped context, so scope filtering - // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => { - /* v8 ignore next -- a dequeue of an id this manager never admitted needs - * another sender on the same child, which no current path allows. */ - if (activation.accepted.delete(item.message.id)) this.wake(activation) - }) - handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => { - // Deleting every id in the batch is unconditional; waking once afterwards - // costs nothing and avoids branching on which ids this manager admitted. - for (const item of items) activation.accepted.delete(item.message.id) - this.wake(activation) - }) - // Resident: publish the start edge before any turn can run, so observers - // see this epoch before its first request. - observer.start(handle.agent) this.watchSettlement(activation) return activation } + /** + * Release an Activation whose start edge was not published. The memoized + * transaction remains in the live map until handle disposal settles, so a + * concurrent drain or delivery observes the same closing boundary. + */ + private rollbackUnpublished(activation: Activation): Promise { + return (activation.disposal ??= (async () => { + try { + await activation.handle.dispose() + } finally { + this.activations.delete(activation.childId) + this.releaseOwnership(activation.childId) + } + })()) + } + /** * Register the child in a continuation-managed parent's owned set before the * child can run, so that parent cannot settle while the child is live. A @@ -637,12 +651,36 @@ export class SubagentContinuationManager { return message.id } + /** + * Cross the final admission cutoff and submit without yielding. Signal abort, + * manager drain, or Activation disposal that wins before this synchronous + * span rejects without inbox acceptance. + */ + private submitAdmitted( + activation: Activation, + content: ContentBlock[], + source: MessageSource, + parent: Agent, + signal: AbortSignal, + ): MessageId { + signal.throwIfAborted() + this.assertAdmitting() + /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change + * this field between the caller's live check and this no-await boundary. */ + if (disposalOf(activation) !== undefined) { + throw new SubagentError( + `subagent "${activation.childId}" activation is being disposed; the message was not accepted`, + 'ACTIVATION_CLOSING', + ) + } + return this.submit(activation, content, source, parent) + } + /** * Authorize delivery to a live Activation. A parent must be the exact live * direct parent recorded in the child's durable header. */ - private async authorizeLive(parent: Agent, activation: Activation): Promise { - await Promise.resolve() + private authorizeLive(parent: Agent, activation: Activation): void { this.authorizeLineage( parent, activation.childId, @@ -762,6 +800,12 @@ export class SubagentContinuationManager { // Capture the child-dependent edge data while the child is still live: // handle disposal unregisters it, and consumers read its log and scope. activation.observer.capture(activation.handle.agent) + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) } finally { try { await activation.handle.dispose() diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5d66ff8da5..cf07d4aff1 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -303,7 +303,7 @@ export class SubagentService extends Service { return provider } - /** Resolve the optional Task-backed continuation runtime or fail loud. */ + /** Resolve the optional continuable-subagent manager or fail loud. */ private requireContinuations(): SubagentContinuationManager { if (this.continuations === undefined) { throw new SubagentError( diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index dd224b68de..13eafddf3d 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -43,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } if (eventName === 'subagent/start') { const info = args[0] as SubagentRunInfo - if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`) - if (String(info.runId).length === 0 || String(info.id).length === 0) { - fail('subagent/start runId and child id must be non-empty') + // Provider availability is an admission-time relationship. A ready + // one-shot run may outlive provider removal, and a cold-resumed Activation + // carries durable provider provenance without dispatching through it. + if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) { + fail('subagent/start provider, runId, and child id must be non-empty') } if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`) stagedStarts.add(info) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5c703c6ce5..aff85acd6a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -35,7 +35,11 @@ export function SubagentRunId(id: string): SubagentRunId { export interface SubagentRunInfo { /** Unique identity shared with the paired terminal event. */ readonly runId: SubagentRunId - /** The provider that established the run. */ + /** + * Provider provenance for this run or Activation epoch. The named provider + * may be absent when an accepted run becomes ready or a persisted Activation + * cold-resumes, because neither lifecycle depends on continued registration. + */ readonly provider: string /** The child agent's id. */ readonly id: SessionId @@ -50,7 +54,7 @@ export interface SubagentRunInfo { export interface SubagentRunEndInfo { /** Unique identity shared with the paired start event. */ readonly runId: SubagentRunId - /** The provider that ran it. */ + /** The same provider provenance carried by the paired start event. */ readonly provider: string /** The child agent's id. */ readonly id: SessionId diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 02dd7896ca..4206ce299d 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -14,12 +14,14 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' +import InvariantService from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts' +import * as SubagentInvariant from '../src/invariant.ts' type Script = ConstructorParameters[0] @@ -228,6 +230,24 @@ describe('SubagentService.startContinuable', () => { }) }) + it('rolls an unpublished Activation back when lifecycle publication fails', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', info => void ends.push(info)) + ctx.on('internal/dispatch', (_mode, eventName) => { + if (eventName === 'subagent/start') throw new Error('start publication failed') + }, { global: true }) + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toThrow(/start publication failed/) + + await vi.waitFor(() => { + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + expect(ends).toEqual([]) + await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + }) + it('rejects a continuable child that would exceed the configured depth cap', async () => { const { ctx, parent } = await setup([]) await expect(ctx.subagents.startContinuable({ @@ -237,6 +257,15 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) + it('rejects an invalid continuable depth cap before provider preparation', async () => { + const { ctx, parent } = await setup([]) + await expect(ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { prompt: message('deep'), parent, maxDepth: Number.NaN }, + })).rejects.toThrow(/non-negative safe integer/) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) + }) + it('omits undeclared composition fields from the descriptor', async () => { const { ctx } = await setup([]) // A routeless parent declares no provider/model, and this start declares no @@ -402,6 +431,38 @@ describe('SubagentService.followup residency routing', () => { expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1) }) + it('cold-resumes after the initial provider unregisters', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + await ctx.plugin(InvariantService) + await ctx.plugin(SubagentInvariant) + const disposeProvider = ctx.subagents.registerProvider({ + name: 'retired', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('one-shot start is not used') }, + prepareContinuable: () => Promise.resolve({}), + }) + const starts: SubagentRunInfo[] = [] + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/start', info => void starts.push(info)) + ctx.on('subagent/end', info => void ends.push(info)) + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'retired')) + await waitNoActivation(ctx, started.childId) + disposeProvider() + expect(ctx.subagents.getProvider('retired')).toBeUndefined() + + await expect(followup(ctx, parent, started.childId, message('continue without provider'))) + .resolves.toBeTypeOf('string') + await waitNoActivation(ctx, started.childId) + await vi.waitFor(() => { expect(ends).toHaveLength(2) }) + + expect(starts.map(info => info.provider)).toEqual(['retired', 'retired']) + expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId)) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider']) + }) + it('wakes a waiting Activation instead of cold-resuming it', async () => { const releaseGrandchild = Promise.withResolvers() const adapter = new GatedAdapter([ @@ -615,6 +676,48 @@ describe('continuable durability and teardown', () => { .rejects.toMatchObject({ code: 'DRAINING' }) }) + it('rejects an initial prompt when drain starts after materialization', async () => { + const { ctx, parent } = await setup([]) + const drains: Promise[] = [] + const accepted: MessageId[] = [] + ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) }) + ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) }) + + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await Promise.all(drains) + + expect(accepted).toEqual([]) + expect(ctx.agents.list()).toEqual([parent]) + }) + + it('admits a live follow-up before a later drain can begin disposal', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const order: string[] = [] + child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + order.push('enqueue') + } + }) + child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) + + const delivery = followup(ctx, parent, started.childId, message('before drain')) + // Let the child-lock operation reach the live admission cutoff. Admission + // and inbox submission must then complete in one synchronous span. + await Promise.resolve() + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + + await expect(delivery).resolves.toBeTypeOf('string') + await drained + expect(order).toEqual(['enqueue', 'cancel']) + }) + it('has no automatic replay for an accepted but unlogged message', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }]) @@ -744,6 +847,29 @@ describe('continuable review regressions', () => { expect(ends[0]!.stopReason).toBe('error') }) + it('reports a pre-disposal teardown failure on the terminal edge', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', info => void ends.push(info)) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map void } }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + activation.observer.capture = () => { throw new Error('capture failed') } + + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('error') + }) + it('cancels a running turn before the final durability checkpoint', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }]) @@ -797,8 +923,8 @@ describe('continuable review regressions', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - // Cancel from the synchronous enqueue observer: the discard fires before - // `followup()` returns, so the id is discarded before it can be recorded. + // Cancel from the synchronous enqueue observer: the discard fires after the + // id is recorded but before `followup()` returns. const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) @@ -815,6 +941,35 @@ describe('continuable review regressions', () => { expect(hasUserText(loaded.events, 'doomed')).toBe(false) }) + it('releases older ids discarded during a later admission window', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + + await followup(ctx, parent, started.childId, message('queued')) + expect(activation.accepted.size).toBe(1) + const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => { + if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + child.cancel({ kind: 'user' }) + } + }) + await followup(ctx, parent, started.childId, message('doomed')) + off() + + expect(activation.accepted.size).toBe(0) + releaseFirst.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + it('reports completed when no ordinary turn closed', async () => { const { ctx, parent } = await setup([]) const ends: SubagentRunEndInfo[] = [] diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index ac3a919862..e288b77a86 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -68,10 +68,10 @@ describe('subagent invariants', () => { it('rejects malformed and unpaired run transitions', async () => { const ctx = await setup() - expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/) - ctx.emit('subagent/provider-added', provider('mock')) + expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) }) + .toThrow(/provider, runId, and child id must be non-empty/) expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) }) - .toThrow(/runId and child id must be non-empty/) + .toThrow(/provider, runId, and child id must be non-empty/) emitRun(ctx, 'subagent/start', start()) expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/) expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) }) @@ -79,4 +79,14 @@ describe('subagent invariants', () => { expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) }) .toThrow(/identity diverges/) }) + + it('accepts historical provider provenance after registration ends', async () => { + const ctx = await setup() + const historical = provider('historical') + ctx.emit('subagent/provider-added', historical) + ctx.emit('subagent/provider-removed', historical.name) + + emitRun(ctx, 'subagent/start', start({ provider: historical.name })) + emitRun(ctx, 'subagent/end', end({ provider: historical.name })) + }) }) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index c717ced0a2..f8ffb0af2c 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md -README.md: b62870217e0eaf57c1cd16204c703aada694d4f2 -README.zh.md: 24a4b7b69a2f95533e4f0b963156fce0aad46bf4 +README.md: 5023862cba39769248a9f6cbe935d6397df39266 +README.zh.md: a5812704609edd38aedc344b4c64044fbf32c8a8 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index b62870217e..5023862cba 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. -The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It supplies exact live parent authority (`{ kind: 'parent', agent }`) from `exec.agent` and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index 24a4b7b69a..a581270460 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -4,7 +4,7 @@ 可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 -本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它从 `exec.agent` 提供准确的实时父级权限(`{ kind: 'parent', agent }`),并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 +本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。 ## 模型体验 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ed46fd687d..2bbfe56b2c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -428,7 +428,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], - note: 'Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', + note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', From 8f3613c4b7d34f4c4ad482f0e73fe053effcb4aa Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 21:51:31 +0800 Subject: [PATCH 21/66] fix(subagent): close final continuation races --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +-- ...8-continuable-subagent-conversations.zh.md | 12 +-- packages/hooks/hooks-claude/src/index.ts | 3 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/README.zh.md | 4 +- .../subagent/subagent/src/continuation.ts | 72 ++++++++------ .../subagent/tests/continuation.spec.ts | 97 +++++++++++++++++++ 9 files changed, 162 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 879a8ecf22..816700d7e7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -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: e0119975d5f815886d671959efdc3028a5929f46 -2026-07-28-continuable-subagent-conversations.zh.md: fdf34d68260f70ef34682f0150a43aa1539dc767 +2026-07-28-continuable-subagent-conversations.md: ec194314d88958becde4672a08570fd1facacb3c +2026-07-28-continuable-subagent-conversations.zh.md: 7c776a4e34e16c1cfd56c8964f25b4f4176001dd diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index e0119975d5..ec194314d8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -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(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. +`ctx.subagents.followup(parent, childId, content, { source, signal })` remains the sole continuation-message operation. The exact live parent Agent authorizes delivery; cold resume checks that authority before reconstruction and every path checks it again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize 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. @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission, then disposes every live Activation forest in child-first order and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, and Activation disposal in the same synchronous span as inbox submission, so teardown that begins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +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. The manager then awaits every materialization that already passed admission until it either installs a resident Activation or completes rollback, snapshots the stable live forest, disposes it child-first, and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. The manager snapshots the live roots after closing admission, stops its outward lifecycle notifications before cancellation, and retains its internal ownership bookkeeping until every handle settles. Each Activation has one memoized disposal promise so host shutdown, manager unload, child release, and normal settlement can converge without double release. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining handles, and the aggregate drain reports failure after all branches settle. Durable child Sessions survive this process-local teardown. @@ -127,7 +127,7 @@ A later host UI may expose separate **Steer** and **Follow up** actions. Host st 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 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. +This version authorizes only the durable child's direct parent. The manager checks `SessionHeader.parentSession` against the exact live parent Agent at the final no-await inbox-admission boundary before registering the child in that parent's `ownedChildren`; cold resume also performs an earlier check before reconstruction for fail-fast rejection. Other Agents, ancestors, hosts, teams, and workflows remain rejected until a concrete consumer justifies another authority protocol. Parent-originated delivery requires the parent to be live when admitted and keeps it live through the ownership relationship. @@ -185,7 +185,7 @@ 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 through a closing transaction visible to concurrent delivery and drain; lifecycle publication failure emits no unmatched terminal edge. - Cold resume calls `ctx.agents.resume()` from the continuation manager and never dispatches through or requires the initial subagent provider; the persisted provider name remains lifecycle provenance after provider removal, while `SubagentProvider.resume?()` and `SubagentProviderResumeRequest` are absent. - A continuable Activation directly owns `AgentHandle` and never creates, wraps, or retains `SubagentRun`; `SubagentProvider.start()` and `SubagentRun` remain one-shot-only, without `SubagentRun.steer?()`. -- `followup()` accepts only the exact live direct parent; durable message provenance cannot authorize delivery. +- `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; 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. @@ -194,11 +194,11 @@ The implementation pins these behaviors: - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. -- Host and manager teardown synchronously enter draining, reject new materialization and delivery, stop manager-owned outward notifications, dispose every snapshotted live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- Host and manager teardown synchronously enter draining, reject new materialization and delivery, await every admitted materialization through publication or rollback, stop manager-owned outward notifications, dispose the stable live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, provider-independent cold resume, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, drain quiescence for a materialization caught between Agent publication and Activation registration, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index fdf34d6826..7c776a4e34 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -44,7 +44,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的 `SubagentProvider.start()` 和 `SubagentRun` 只保留在不变的 one-shot 路径上。可继续激活直接持有自身的 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;因此,`SubagentRun.steer?()` 不存在。 -`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 +`ctx.subagents.followup(parent, childId, content, { source, signal })` 仍是唯一的继续执行消息操作。确切的在线 parent Agent 授权投递;冷恢复会在重建前检查该权限,每条路径还会在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。`source` 仍是持久化来源信息,不赋予任何权限。面向模型的 `send_message` 工具只保留稳定的 `subagent_id` 和 `message` 字段,并始终提交一个 follow-up 轮次。start 和 follow-up 都返回已接受的 `MessageId`,两者都不报告管理器如何物化激活。 对于 start 和 follow-up,调用方 signal 只在 inbox 接受消息前持有查找、物化和准入。操作返回 `MessageId` 后,管理器会独立持有该激活;调用方之后的取消不会取消已接受的轮次,也不会 dispose child。 @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入;然后按 child-first 顺序 dispose 整个在线激活森林,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining 和激活 dispose,因此在接受前开始的拆卸会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入。管理器随后等待每个已经通过准入的物化过程,直至它安装驻留激活或完成回滚,再对稳定的在线森林创建快照,按 child-first 顺序 dispose,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining、激活 dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。管理器在关闭准入后对在线根节点创建快照,在取消前停止自身的对外生命周期通知,并保留内部所有权簿记,直至每个 handle 都结算。每次激活有一个记忆化的 dispose promise,使宿主关闭、管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余 handle,聚合 drain 则在所有分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 @@ -127,7 +127,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 权限来自确切的在线 Agent 工具上下文。`MessageSource` 和 `senderSessionId` 是准入后的持久化来源信息,不是由调用方控制的权限。 -本版本只授权持久化 child 的直接 parent。管理器会根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`,然后才将 child 注册到该 parent 的 `ownedChildren`。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。 +本版本只授权持久化 child 的直接 parent。管理器会在将 child 注册到该 parent 的 `ownedChildren` 之前,于最终无 await 的 inbox 准入边界根据确切的在线 parent Agent 检查 `SessionHeader.parentSession`;冷恢复还会在重建前执行一次更早的检查,以便快速失败。其他 Agent、祖先、宿主、团队和工作流仍被拒绝,直至有具体消费方证明另一种权限协议合理。 由 parent 发起的投递要求 parent 在准入时在线,并通过所有权关系使其继续在线。 @@ -185,7 +185,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 初始提示词被 inbox 接受前的每条失败路径都会导致操作被拒绝且不返回 id,并通过一个对并发投递和 drain 可见的关闭事务回滚已创建的任何 handle、激活和 parent `ownedChildren` 成员关系;生命周期发布失败不会产生无配对的终止事件。 - 冷恢复由继续执行管理器调用 `ctx.agents.resume()`,绝不通过或依赖初始 subagent 提供方;提供方移除后,持久化的提供方名称仍作为生命周期来源信息保留,且 `SubagentProvider.resume?()` 和 `SubagentProviderResumeRequest` 均不存在。 - 可继续激活直接持有 `AgentHandle`,绝不创建、包装或保留 `SubagentRun`;`SubagentProvider.start()` 和 `SubagentRun` 只用于 one-shot,且没有 `SubagentRun.steer?()`。 -- `followup()` 只接受确切的在线直接 parent;持久化消息来源信息不能授权投递。 +- `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 - 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 @@ -194,11 +194,11 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 -- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,停止由管理器负责的对外通知,按 child-first 顺序 dispose 处于快照中的整个在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,等待每个已获准的物化过程完成发布或回滚,停止由管理器负责的对外通知,按 child-first 顺序 dispose 稳定的在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、不依赖提供方的冷恢复、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、drain 会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 39a4ce1dc3..1a50c35c41 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -120,7 +120,8 @@ export function apply(ctx: Context, config: Config): void { const detached = createDetachedRuns() // Only the start edge guarantees registry access. Retain each local child // through its paired end so stop hooks keep the session workspace after the - // handle unregisters the agent. + // handle unregisters the agent. Every retained entry relies on that paired + // end; a producer that can omit it must provide another release edge. const subagentChildren = new Map() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index b8ed113445..0a3a116d38 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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: 0e59a1ad5f256de4d6505d3d00d3790d7738a457 -README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0 +README.md: 06047ac87e84d50d8dc1a965c7d2499cbe58076d +README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 0e59a1ad5f..06047ac87e 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,11 +31,11 @@ Multiple providers may coexist under different names. This lets a deployment exp | `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(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. | +| `drainContinuable()` | Close continuable admission synchronously, await every materialization that already passed admission through publication or rollback, then dispose the stable 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. -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. +Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. 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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 073b490352..206a7d6e95 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,11 +31,11 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `drainContinuable()` | 同步关闭可继续准入,然后以子先于父的顺序 dispose 每一个实时 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | +| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 -后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 +后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 109c5b666f..2a4fdefc24 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -156,11 +156,22 @@ interface Activation { poke: PromiseWithResolvers } +/** Inputs shared by fresh and resumed Activation materialization. */ +interface MaterializeInputs { + childId: SessionId + provider: string + parent: Agent + /** Creation inputs; absent for a cold resume, which loads the persisted session. */ + create?: { seed: readonly SessionEvent[]; meta: NonNullable } + agentOptions: AgentOptions + composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } + signal: AbortSignal +} + /** * Read one Activation's current disposal transaction. This indirection exists - * because a mutable field read inside a long-lived closure narrows to its - * last-seen value, which would flatten these genuine runtime checks to - * constants. + * because TypeScript would otherwise narrow repeated reads of the mutable field + * inside a long-lived closure to constants instead of re-reading runtime state. * @param activation - the Activation to inspect. * @returns the in-flight or settled disposal, or `undefined` while resident. */ @@ -206,6 +217,8 @@ class ChildLock { export class SubagentContinuationManager { /** Child session id → its live Activation. Process-local, never durable. */ private activations = new Map() + /** Materializations admitted before drain, tracked through publication or rollback. */ + private readonly materializations = new Set>() private readonly locks = new ChildLock() /** Structural Cordis owner of every Activation handle. */ private readonly ownerCtx: Context @@ -332,7 +345,6 @@ export class SubagentContinuationManager { if (activation.disposal !== undefined) { return activation.disposal.then(() => undefined, () => undefined) } - this.authorizeLive(parent, activation) return this.submitAdmitted(activation, content, options.source, parent, options.signal) }) /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that @@ -345,17 +357,20 @@ export class SubagentContinuationManager { } /** - * Dispose every live Activation forest child-first and await all handles. - * Sibling branches drain independently: one failure is recorded but never - * prevents the remaining handles from being attempted, and the aggregate - * rejects only after every branch settles. - * @returns once every snapshotted Activation released its handle. + * Close admission, await every already-admitted materialization through + * publication or rollback, then dispose the stable live Activation forest + * child-first. Sibling branches drain independently: one failure is recorded + * but never prevents the remaining handles from being attempted, and the + * aggregate rejects only after every branch settles. + * @returns once materialization is quiescent and every live Activation released its handle. * @throws an aggregate error when any branch failed to release. */ async drain(): Promise { - // Close admission synchronously before the first await, so no new creation, - // cold resume, or delivery can race the snapshot below. + // Close admission synchronously before the first await. Materializations + // already past that cutoff remain tracked until their handle is installed + // or rollback completes, producing a stable forest for the later snapshot. this.draining = true + await Promise.all([...this.materializations]) // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -489,16 +504,22 @@ export class SubagentContinuationManager { * a continuation-managed parent. Rejection leaves no Activation, no handle, * and no ownership membership. */ - private async materialize(inputs: { - childId: SessionId - provider: string - parent: Agent - /** Creation inputs; absent for a cold resume, which loads the persisted session. */ - create?: { seed: readonly SessionEvent[]; meta: NonNullable } - agentOptions: AgentOptions - composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } - signal: AbortSignal - }): Promise { + private materialize(inputs: MaterializeInputs): Promise { + this.assertAdmitting() + const settled = Promise.withResolvers() + this.materializations.add(settled.promise) + return this.materializeTracked(inputs).finally(() => { + this.materializations.delete(settled.promise) + settled.resolve() + }) + } + + /** + * Perform one tracked materialization. The caller keeps the drain barrier + * registered until this either returns a resident Activation or finishes + * rollback. + */ + private async materializeTracked(inputs: MaterializeInputs): Promise { const { childId, provider, parent } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and @@ -673,19 +694,12 @@ export class SubagentContinuationManager { 'ACTIVATION_CLOSING', ) } - return this.submit(activation, content, source, parent) - } - - /** - * Authorize delivery to a live Activation. A parent must be the exact live - * direct parent recorded in the child's durable header. - */ - private authorizeLive(parent: Agent, activation: Activation): void { this.authorizeLineage( parent, activation.childId, activation.handle.agent.session.header.parentSession, ) + return this.submit(activation, content, source, parent) } /** diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 4206ce299d..79a1376b54 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -691,6 +691,29 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.list()).toEqual([parent]) }) + it('waits for a published materialization to finish rollback before drain resolves', async () => { + const { ctx, parent } = await setup([]) + const order: string[] = [] + const drains: Promise[] = [] + ctx.on('agent/created', (child) => { + if (child === parent) return + const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') }) + drains.push(draining) + }) + ctx.on('agent/disposed', (child) => { + if (child !== parent) order.push('disposed') + }) + + // `agent/created` runs after registry publication but before materialize() + // receives the handle and installs the Activation. + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await Promise.all(drains) + + expect(order).toEqual(['disposed', 'drain']) + expect(ctx.agents.list()).toEqual([parent]) + }) + it('admits a live follow-up before a later drain can begin disposal', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) @@ -739,6 +762,80 @@ describe('continuable durability and teardown', () => { }) describe('continuable review regressions', () => { + it('rechecks exact parent liveness after cold-resume materialization', async () => { + const { ctx } = await setup([textResponse('first')]) + const parentId = SessionId('replaceable-parent') + const originalParent = await ctx.agents.create({ + sessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const started = await ctx.subagents.startContinuable(startSpec(originalParent.agent)) + await waitNoActivation(ctx, started.childId) + + const manager = (ctx.subagents as unknown as { + continuations: { ownerCtx: Context } + }).continuations + const ownerAgents = manager.ownerCtx.agents + const originalResume = ownerAgents.resume.bind(ownerAgents) + const resumed = Promise.withResolvers() + const releaseResume = Promise.withResolvers() + const resumeSpy = vi.spyOn(ownerAgents, 'resume').mockImplementation(async (options) => { + const handle = await originalResume(options) + resumed.resolve(undefined) + await releaseResume.promise + return handle + }) + + const delivery = followup( + ctx, + originalParent.agent, + started.childId, + message('must not cross parent replacement'), + ) + await resumed.promise + await originalParent.dispose() + const replacement = await ctx.agents.create({ + sessionId: parentId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + releaseResume.resolve(undefined) + + await expect(delivery).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + resumeSpy.mockRestore() + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(hasUserText(loaded.events, 'must not cross parent replacement')).toBe(false) + await replacement.dispose() + }) + + it('clears the accepted reservation when Agent.followup throws', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const manager = (ctx.subagents as unknown as { + continuations: { + activations: Map }> + } + }).continuations + const activation = manager.activations.get(started.childId)! + const realFollowup = child.followup.bind(child) + child.followup = () => { + throw new Error('synthetic inbox failure') + } + + await expect(followup(ctx, parent, started.childId, message('throws'))) + .rejects.toThrow(/synthetic inbox failure/) + expect(activation.accepted.size).toBe(0) + + child.followup = realFollowup + const drained = ctx.subagents.drainContinuable() + hold.resolve(undefined) + await drained + }) + it('reports the child\'s own terminal reason, not teardown success', async () => { // The child hits its token ceiling; teardown still succeeds. const { ctx, parent } = await setupWith(new MockAdapter([ From 191c8cd64000658c3134776132225c71566eb814 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 31 Jul 2026 13:51:46 +0800 Subject: [PATCH 22/66] fix(acp): scope connection-owned continuation drain --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 12 +- ...8-continuable-subagent-conversations.zh.md | 12 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 12 + docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 2 +- packages/acp/acp/README.zh.md | 2 +- packages/acp/acp/src/index.ts | 16 +- packages/acp/acp/tests/dispose.spec.ts | 11 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 3 +- packages/subagent/subagent/README.zh.md | 3 +- .../subagent/subagent/src/continuation.ts | 326 +++++++++++++----- packages/subagent/subagent/src/index.ts | 17 + .../subagent/tests/continuation.spec.ts | 193 +++++++++++ .../subagent/subagent/tests/service.spec.ts | 3 +- 22 files changed, 522 insertions(+), 118 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 816700d7e7..87101c479a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -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: ec194314d88958becde4672a08570fd1facacb3c -2026-07-28-continuable-subagent-conversations.zh.md: 7c776a4e34e16c1cfd56c8964f25b4f4176001dd +2026-07-28-continuable-subagent-conversations.md: 8e867254a726c57200936cff6c83831abb4c66ad +2026-07-28-continuable-subagent-conversations.zh.md: 3383a0b8fd7b17ad40c3a03f32a78df058afb120 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index ec194314d8..8e867254a7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -107,9 +107,9 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. The host first asks the manager to enter draining synchronously, which rejects new creation, resume, and delivery admission. The manager then awaits every materialization that already passed admission until it either installs a resident Activation or completes rollback, snapshots the stable live forest, disposes it child-first, and awaits all `AgentHandle.dispose()` calls. Every materialized start and live delivery rechecks caller cancellation, draining, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after that drain settles may the host dispose top-level Agents and the manager scope. Manager unload uses the same drain. +Top-level teardown is host-owned rather than represented as another Activation. Manager unload uses `drainContinuable()` to close manager-wide admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents instead uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only the global drain precedes manager-scope disposal. -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. +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. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown. ### Deferred report delivery @@ -135,7 +135,7 @@ Parent-originated delivery requires the parent to be live when admitted and keep 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. +Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions. Each turn requests the Session durability checkpoint, and final Activation settlement requires the manager to inspect `ctx.sessions.flush()` rather than ignore its boolean result. `true` confirms that at least one durability listener participated and every listener settled successfully. `false` or rejection reports `DURABILITY_FAILED`; normal background settlement logs the lifecycle failure, while an explicit host or manager drain includes it in the aggregate rejection after all branches settle. Either way, the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume. @@ -188,17 +188,17 @@ The implementation pins these behaviors: - `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; 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 public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup. - This version exposes no subagent steering operation or current-turn controller state. - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained. - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation. - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph. - Final Activation settlement treats only `ctx.sessions.flush(child.session) === true` as durability confirmation; `false` and rejection report `DURABILITY_FAILED`, still dispose the child handle, and still release parent ownership so durability failure cannot leak a `waiting` Activation. -- Host and manager teardown synchronously enter draining, reject new materialization and delivery, await every admitted materialization through publication or rollback, stop manager-owned outward notifications, dispose the stable live Activation forest child-first, await every branch despite individual failures, and only then dispose top-level Agents and the manager scope; a private activation-owner scope preserves this order against Cordis effect unwinding, and one memoized disposal promise per Activation makes concurrent normal settlement idempotent. +- Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope. - This version exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup. - Session logs reconstruct only messages that were actually written, with their admitted provenance; inbox-accepted but unlogged messages have no restart guarantee. - No continuable-subagent path creates or depends on a Task, `TaskId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper. -- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, drain quiescence for a materialization caught between Agent publication and Activation registration, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. +- Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages. - Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, both `false` and rejection from the final durability checkpoint without ownership leaks, and the absence of public subagent cancellation, steering, and report tools. - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering, report delivery, and automatic parent wakeup, retained waiting `AgentHandle`, and child-first disposal. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 7c776a4e34..3383a0b8fd 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -107,9 +107,9 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。宿主首先要求管理器同步进入 draining,拒绝新的创建、恢复和投递准入。管理器随后等待每个已经通过准入的物化过程,直至它安装驻留激活或完成回滚,再对稳定的在线森林创建快照,按 child-first 顺序 dispose,并等待全部 `AgentHandle.dispose()` 调用。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、draining、激活 dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有该 drain 结算后,宿主才能 dispose 顶层 Agent 和管理器作用域。管理器卸载使用相同的 drain。 +顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载使用 `drainContinuable()` 同步关闭管理器全局准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主则使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有全局 drain 会先于管理器作用域 dispose。 -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 会话。 +activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 ### 延后的报告投递 @@ -135,7 +135,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。 -宿主和管理器拆卸仍是覆盖整个生命周期的停止路径。它会关闭准入,按 child-first 顺序 dispose 每个在线激活森林,并保留持久化会话。 +宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。 每个轮次都会请求执行会话持久性检查点,激活最终结算时,管理器必须检查 `ctx.sessions.flush()`,而不能忽略其布尔结果。`true` 确认至少有一个持久性 listener 参与,且所有 listener 都成功结算。`false` 或 rejection 会报告 `DURABILITY_FAILED`;普通后台结算会记录该生命周期失败,显式的宿主或管理器 drain 则会在所有分支结算后,将其纳入聚合 rejection。无论结果如何,管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。 @@ -188,17 +188,17 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect - `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。 - 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。 - `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。 -- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,宿主和管理器拆卸则保留 child-first 全局清理。 +- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理。 - 本版本不暴露 subagent steering 操作或当前轮次控制方状态。 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。 - 激活最终结算时,只有 `ctx.sessions.flush(child.session) === true` 才确认持久性;`false` 和 rejection 会报告 `DURABILITY_FAILED`,但仍会 dispose child handle 并释放 parent 所有权,使持久性失败不会泄漏 `waiting` 激活。 -- 宿主和管理器拆卸会同步进入 draining,拒绝新的物化和投递,等待每个已获准的物化过程完成发布或回滚,停止由管理器负责的对外通知,按 child-first 顺序 dispose 稳定的在线激活森林,即使个别分支失败也会等待所有分支,之后才 dispose 顶层 Agent 和管理器作用域;私有 activation-owner 作用域会确保 Cordis effect 的逆序撤销不破坏该顺序,每次激活使用一个记忆化的 dispose promise,使并发的正常结算保持幂等。 +- 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。 - 本版本不暴露 `report` 工具,不提供从 child 到 parent 的内容投递,也不自动唤醒 parent。 - 会话日志只能根据准入来源重建实际写入的消息;已被 inbox 接受但未写入日志的消息没有重启保证。 - 可继续 subagent 路径不创建或依赖 Task、`TaskId`、Task 完成通知、Task 取消或中间的带结果执行包装层。 -- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、drain 会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 +- 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。 - 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、最终持久性检查点返回 `false` 和 rejection 时都不泄漏所有权,以及不存在公开 subagent 取消、steering 和报告工具这一事实。 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering、报告投递和自动唤醒 parent、保留等待中的 `AgentHandle` 以及 child-first dispose。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 420b0f4b66..9a1437ff5f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:67`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fd51125a06..d574b8b1dd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1989,6 +1989,18 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti */ async drainContinuable(): Promise +/** + * Close continuable admission below exact live parent Agents, stop only their + * visible descendant Activations synchronously, then await admitted scoped + * materializations and release those forests child-first. The scoped cutoff + * lasts until each exact parent leaves the registry; unrelated parent trees + * remain live. + * @param parents - exact host-owned parent Agents entering teardown. + * @returns once every retained descendant Activation released its `AgentHandle`. + * @throws an aggregate error after all scoped branches settle when any failed. + */ +async drainContinuableDescendants(parents: readonly Agent[]): Promise + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 39393ba161..1e0a7546d3 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -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: e160c596acb55f0e94cba84b8c79355c966eb51a -subagent.zh.md: 6b934a523fa0ea5d53ea9a670e56b72b7f785593 +subagent.md: eff78bae6fcf7440cce66f122771ff623a5eb3c7 +subagent.zh.md: a898f438c257db05a3d84e6318ce23e1f366e193 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index e160c596ac..eff78bae6f 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -129,7 +129,7 @@ For both operations the caller signal owns lookup, materialization, and admissio Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens 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. -Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` is the lifecycle-wide stop path: it closes admission synchronously, then disposes every live Activation forest child-first, awaiting every branch despite individual failures. Durable child Sessions survive that process-local teardown. +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` closes manager-wide admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 6b934a523f..a898f438c2 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -129,7 +129,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 -只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 是覆盖整个生命周期的停止路径:它同步关闭准入,随后以子级优先的方式 dispose 每一片存活的 Activation 森林,尽管个别分支失败仍会等待每个分支。持久化子会话不受该进程内拆卸的影响。 +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 会关闭管理器全局准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7cccf7ab63..c54b36368d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index d359613cd6..7c8be3dc18 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -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/acp/acp/README.md -README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 -README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5 +README.md: 9a48fdec3330cd364c1ab6de4c117b20af0f443f +README.zh.md: 65732f41277a8760bfd2824aea12b0f240ae8025 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 1b188b994d..9a48fdec33 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -35,7 +35,7 @@ Committed-message output intentionally trades token-by-token latency for a clean ## Lifecycle -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent. +Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting their loop/session cleanup. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. ## Running diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index c1e7d045b5..65732f4127 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -35,7 +35,7 @@ ## 生命周期 -客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。 +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待它们的循环/会话清理完成。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 ## 运行 diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 2c877df7c4..58823ff407 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -49,8 +49,11 @@ export const inject = ['agents'] * shutdown hook; an absent service means nothing continuable was materialized. */ interface ContinuableDrain { - /** Close continuable admission, then dispose every live Activation child-first. */ - drainContinuable(): Promise + /** + * Close admission below exact host-owned parents, then dispose only their + * continuable descendants child-first. + */ + drainContinuableDescendants(parents: readonly Agent[]): Promise } /** Preserve invalid-parameter detail in the SDK wire error message. */ @@ -345,15 +348,16 @@ export function apply(ctx: Context, config: AcpConfig): void { } quiescing = (async () => { // Continuable subagents outlive the turn that started them, and their - // Activations own descendant teardown. Drain that forest child-first - // BEFORE disposing the top-level agents, so no descendant is left holding - // a runtime its owner already released. + // Activations own descendant teardown. Drain only these sessions' forests + // child-first BEFORE disposing the top-level agents, so no descendant is + // left holding a runtime its owner already released and another frontend + // sharing this Context remains live. // Read the one teardown method structurally: the bridge needs no other // part of the subagent seam, so it does not depend on that package. const subagents = ctx.get('subagents') as ContinuableDrain | undefined if (subagents !== undefined) { try { - await subagents.drainContinuable() + await subagents.drainContinuableDescendants(records.map(record => record.agent)) } catch (error: unknown) { logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) } diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 4be0810513..7638c4bd9e 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' @@ -28,21 +29,25 @@ describe('ACP connection ownership', () => { it('drains continuable subagents before disposing its own sessions', async () => { harness = await makeBridgeHarness() const order: string[] = [] + let drainedParents: readonly Agent[] = [] // A continuable Activation outlives the turn that started it, so the bridge // must release that forest before the agents whose runtime it depends on. harness.ctx.provide('subagents', { - drainContinuable: () => { + drainContinuableDescendants: (parents: readonly Agent[]) => { + drainedParents = parents order.push('drained') return Promise.resolve() }, } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! harness.ctx.on('agent/disposed', () => { order.push('agent disposed') }) await harness.acpFiber.dispose() expect(order).toEqual(['drained', 'agent disposed']) + expect(drainedParents).toEqual([agent]) expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) @@ -51,7 +56,7 @@ describe('ACP connection ownership', () => { const order: string[] = [] const release = Promise.withResolvers() harness.ctx.provide('subagents', { - drainContinuable: async () => { + drainContinuableDescendants: async () => { order.push('drain started') await release.promise order.push('drain finished') @@ -79,7 +84,7 @@ describe('ACP connection ownership', () => { const warnings: string[] = [] harness.ctx.logger.warn = (message: string) => { warnings.push(message) } harness.ctx.provide('subagents', { - drainContinuable: () => Promise.reject(new Error('activation teardown failed')), + drainContinuableDescendants: () => Promise.reject(new Error('activation teardown failed')), } as never) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 59e901b2fe..0990ff339a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -896,6 +896,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async drainContinuable(): Promise', jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', }, + { + signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise', + jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */', + }, { signature: 'registerProvider(provider: SubagentProvider): () => void', jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0a3a116d38..4a0496100e 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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: 06047ac87e84d50d8dc1a965c7d2499cbe58076d -README.zh.md: 206a7d6e95f61ab152829e614cfaf1d15c5bec33 +README.md: 6fab6859e2c15fdb1ded023642cbc593e0457384 +README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 06047ac87e..6fab6859e2 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -32,6 +32,7 @@ Multiple providers may coexist under different names. This lets a deployment exp | `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(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, await every materialization that already passed admission through publication or rollback, then dispose the stable 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. | +| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | `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. @@ -76,7 +77,7 @@ The manager derives three internal residency conditions from Agent quiescence an 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`. +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). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains 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 diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 206a7d6e95..1f59807a54 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -32,6 +32,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | | `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | +| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 @@ -76,7 +77,7 @@ subagent seam 允许一个 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 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算只把 `ctx.sessions.flush(child.session) === true` 视为持久性确认;`false` 或拒绝会报告 `DURABILITY_FAILED`,但仍会 dispose 句柄并释放所有权,因为保留失败的子 agent 会使其祖先永久停留在 `waiting`。 ## 生命周期事件 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2a4fdefc24..2bd89607f8 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -131,6 +131,12 @@ interface Activation { readonly provider: string /** The retained live Agent handle, disposed exactly once at settlement. */ readonly handle: AgentHandle + /** + * Exact live Agent ancestry observed when this Activation materialized. + * Weak membership preserves host-scope identity across an intermediate + * ancestor leaving the registry without retaining that ancestor's runtime. + */ + readonly ancestry: WeakSet /** * Session ids of the child Activations this one owns. Because one Session has * at most one live Activation, the id identifies the live child without @@ -168,6 +174,16 @@ interface MaterializeInputs { signal: AbortSignal } +/** + * One admitted materialization and the exact live ancestry observed at its + * synchronous admission boundary. Retaining identities lets a scoped teardown + * keep waiting even if an intermediate Agent leaves the registry meanwhile. + */ +interface Materialization { + readonly lineage: readonly Agent[] + readonly settled: Promise +} + /** * Read one Activation's current disposal transaction. This indirection exists * because TypeScript would otherwise narrow repeated reads of the mutable field @@ -218,10 +234,17 @@ export class SubagentContinuationManager { /** Child session id → its live Activation. Process-local, never durable. */ private activations = new Map() /** Materializations admitted before drain, tracked through publication or rollback. */ - private readonly materializations = new Set>() + private readonly materializations = new Set() private readonly locks = new ChildLock() /** Structural Cordis owner of every Activation handle. */ private readonly ownerCtx: Context + /** + * Exact roots whose host teardown has begun, with the live lineage members + * observed under each root. Entries remain until that exact root leaves the + * Agent registry, closing admission throughout its host's teardown without + * poisoning a later same-id replacement. + */ + private readonly closingScopes = new Map>() private draining = false constructor( @@ -236,6 +259,9 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx + ctx.on('agent/disposed', (agent) => { + this.closingScopes.delete(agent) + }) ctx.effect(function* (this: SubagentContinuationManager) { yield scope.dispose yield () => this.drain() @@ -258,10 +284,10 @@ export class SubagentContinuationManager { * @returns the durable child id and the accepted initial prompt's message id. */ async startContinuable(spec: ContinuableStartSpec): Promise { - this.assertAdmitting() - this.requirePersistence() const request = spec.request const parent = request.parent + this.assertAdmitting(parent) + this.requirePersistence() assertSubagentMaxDepth(request.maxDepth) const childId = SessionId(randomUUID()) const childDepth = resolveChildDepth(parent, request.maxDepth) @@ -283,7 +309,7 @@ export class SubagentContinuationManager { signal: spec.signal, }) spec.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) const lineageSeedLength = prepared.seed?.length ?? 0 const seed = seedDescriptorTurn(childId, prepared.seed, descriptor) @@ -331,7 +357,7 @@ export class SubagentContinuationManager { content: ContentBlock[], options: SubagentFollowupOptions, ): Promise { - this.assertAdmitting() + this.assertAdmitting(parent) while (true) { const live = await this.locks.run(childId, async () => { const activation = this.activations.get(childId) @@ -350,7 +376,7 @@ export class SubagentContinuationManager { /* 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. */ if (live !== undefined) return live - this.assertAdmitting() + this.assertAdmitting(parent) options.signal.throwIfAborted() /* v8 ignore stop */ } @@ -370,7 +396,7 @@ export class SubagentContinuationManager { // already past that cutoff remain tracked until their handle is installed // or rollback completes, producing a stable forest for the later snapshot. this.draining = true - await Promise.all([...this.materializations]) + await Promise.all([...this.materializations].map(materialization => materialization.settled)) // Snapshot roots after closing admission: a root is an Activation no live // Activation owns, so disposing roots recurses child-first into the forest. const owned = new Set() @@ -396,14 +422,128 @@ export class SubagentContinuationManager { } } - /** Reject new admission once the host or manager began draining. */ - private assertAdmitting(): void { + /** + * Stop only the continuable descendants of exact live host-owned parents. + * Admission stays closed for those parent trees until each exact parent + * leaves the Agent registry; unrelated trees and manager-wide admission stay + * live. + * @param parents - exact live roots whose continuable descendants must stop. + * @returns once every retained descendant Activation released its handle. + * @throws an aggregate error after all scoped branches settle when any failed. + */ + async drainDescendants(parents: readonly Agent[]): Promise { + const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent)) + if (roots.size === 0) return + + // Publish the scoped admission cutoff before the first await. Merge with an + // earlier call for the same exact root so a converging drain cannot forget + // descendants whose release is already in flight. + for (const root of roots) { + this.closingMembers(root).add(root) + } + + const targets: Activation[] = [] + for (const activation of this.activations.values()) { + const lineage = this.liveLineage(activation.handle.agent) + // Strict descendants only: a continuable Agent may itself be a + // host-owned root, and its host remains responsible for that root handle. + const owners = [...roots].filter(root => activation.handle.agent !== root + && activation.ancestry.has(root)) + if (owners.length === 0) continue + targets.push(activation) + for (const owner of owners) { + const members = this.closingMembers(owner) + members.add(activation.handle.agent) + for (const agent of lineage) members.add(agent) + } + } + const materializations = [...this.materializations].filter((materialization) => { + const owners = [...roots].filter(root => materialization.lineage.includes(root)) + for (const owner of owners) { + const members = this.closingMembers(owner) + for (const agent of materialization.lineage) members.add(agent) + } + return owners.length > 0 + }) + + const ownedTargets = new Set() + for (const activation of targets) { + for (const child of activation.ownedChildren) ownedTargets.add(child) + } + const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId)) + + // Open every selected transaction before the materialization barrier. + // Disposal propagates cancellation top-down in the same synchronous span; + // handle release remains child-first. + for (const activation of targets) { + const disposal = this.dispose(activation) + void disposal.catch(() => undefined) + } + + await Promise.all(materializations.map(materialization => materialization.settled)) + const failures = await Promise.all(targetRoots.map(async (activation) => { + try { + await this.dispose(activation) + return undefined + } catch (error: unknown) { + return error + } + })) + const reasons = failures.filter(failure => failure !== undefined) + if (reasons.length > 0) { + throw new SubagentError( + `continuable subagent teardown failed for ${reasons.length} scoped activation(s): ` + + reasons.map(reason => errorChain(reason)).join('; '), + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + } + + /** Return the retained member set for one exact scoped-teardown root. */ + private closingMembers(root: Agent): Set { + const existing = this.closingScopes.get(root) + if (existing !== undefined) return existing + const members = new Set() + this.closingScopes.set(root, members) + return members + } + + /** + * Return the exact currently resolvable ancestry from `agent` upward. The + * first element is always the supplied identity, even when it is already + * stale; each ancestor after it must be the registry's current exact entry. + */ + private liveLineage(agent: Agent): Agent[] { + const lineage = [agent] + const seen = new Set([agent.id]) + let parentSession = agent.session.header.parentSession + while (parentSession !== undefined) { + const parent = this.ctx.agents.get(parentSession) + if (parent === undefined || seen.has(parent.id)) break + lineage.push(parent) + seen.add(parent.id) + parentSession = parent.session.header.parentSession + } + return lineage + } + + /** Reject new admission once the manager or this exact parent tree began draining. */ + private assertAdmitting(agent: Agent): void { if (this.draining) { throw new SubagentError( 'continuable subagents are draining; the operation was not admitted', 'DRAINING', ) } + const lineage = this.liveLineage(agent) + for (const [root, members] of this.closingScopes) { + if (members.has(agent) || lineage.includes(root)) { + throw new SubagentError( + `continuable subagents below parent "${root.id}" are draining; the operation was not admitted`, + 'DRAINING', + ) + } + } } /** @@ -443,7 +583,7 @@ export class SubagentContinuationManager { } // The persistence seam takes no signal; recheck before any child work. options.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) // Authorize the persisted header before folding: only the durable child's // exact live direct parent may continue it. this.authorizeLineage(parent, childId, loaded.meta.parentSession) @@ -505,11 +645,16 @@ export class SubagentContinuationManager { * and no ownership membership. */ private materialize(inputs: MaterializeInputs): Promise { - this.assertAdmitting() + this.assertAdmitting(inputs.parent) const settled = Promise.withResolvers() - this.materializations.add(settled.promise) - return this.materializeTracked(inputs).finally(() => { - this.materializations.delete(settled.promise) + const lineage = this.liveLineage(inputs.parent) + const materialization: Materialization = { + lineage, + settled: settled.promise, + } + this.materializations.add(materialization) + return this.materializeTracked(inputs, lineage).finally(() => { + this.materializations.delete(materialization) settled.resolve() }) } @@ -519,7 +664,10 @@ export class SubagentContinuationManager { * registered until this either returns a resident Activation or finishes * rollback. */ - private async materializeTracked(inputs: MaterializeInputs): Promise { + private async materializeTracked( + inputs: MaterializeInputs, + parentLineage: readonly Agent[], + ): Promise { const { childId, provider, parent } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and @@ -551,6 +699,7 @@ export class SubagentContinuationManager { childId, provider, handle, + ancestry: new WeakSet([handle.agent, ...parentLineage]), ownedChildren: new Set(), observer, disposal: undefined, @@ -562,7 +711,7 @@ export class SubagentContinuationManager { this.activations.set(childId, activation) try { inputs.signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -685,7 +834,7 @@ export class SubagentContinuationManager { signal: AbortSignal, ): MessageId { signal.throwIfAborted() - this.assertAdmitting() + this.assertAdmitting(parent) /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change * this field between the caller's live check and this no-await boundary. */ if (disposalOf(activation) !== undefined) { @@ -767,83 +916,100 @@ export class SubagentContinuationManager { } /** - * Release one Activation child-first: dispose owned children, checkpoint - * durability, dispose the handle, and release parent ownership. Memoized, so - * host shutdown, manager unload, child release, and normal settlement - * converge on one teardown. + * Stop one Activation immediately, then release it child-first. The memoized + * transaction is installed before cancellation or recursive callbacks, so + * admission and reentrant teardown converge on the same owner. * * A failed final checkpoint is reported but never prevents handle disposal or * ownership release, because retaining a failed child would permanently pin * its ancestors in `waiting`. + * @param activation - the residency epoch to stop and release. + * @returns the one disposal transaction owned by this Activation. */ private dispose(activation: Activation): Promise { - return (activation.disposal ??= (async () => { - // The memoized assignment above already closed admission for this child: - // no caller may send to a handle after its disposal transaction begins. - this.wake(activation) - const { childId } = activation - let failure: Error | undefined - try { - // Child-first: every owned child must complete disposal before this - // handle is released. - const children = [...activation.ownedChildren] - .map(child => this.activations.get(child)) - .filter((child): child is Activation => child !== undefined) - const childFailures = await Promise.all(children.map(async (child) => { - try { - await this.dispose(child) - return undefined - } catch (error: unknown) { - return error - } - })) - const reasons = childFailures.filter(reason => reason !== undefined) - if (reasons.length > 0) { - failure = new SubagentError( - `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, - 'ACTIVATION_TEARDOWN_FAILED', - ) + const existing = activation.disposal + if (existing !== undefined) return existing + const completion = Promise.withResolvers() + // Presence is the admission cutoff. Assign it before the async helper starts + // because that helper cancels Agents and may synchronously re-enter callers. + activation.disposal = completion.promise + void this.finishDisposal(activation).then(completion.resolve, completion.reject) + return completion.promise + } + + /** + * Propagate stop synchronously, then finish the child-first release. + * @param activation - the Activation whose disposal transaction is installed. + * @returns once the handle and ownership edge are released. + */ + private async finishDisposal(activation: Activation): Promise { + this.wake(activation) + const { childId } = activation + // Stop top-down before the first await. Slow descendant cleanup may delay + // release, but it cannot let this ancestor continue model or tool work. + activation.handle.agent.cancel({ kind: 'parent' }) + const idle = activation.handle.agent.whenIdle() + const children = [...activation.ownedChildren] + .map(child => this.activations.get(child)) + .filter((child): child is Activation => child !== undefined) + const childDisposals = children.map(child => this.dispose(child)) + + let failure: Error | undefined + try { + // Release remains child-first even though cancellation propagated + // top-down: every owned child completes before this handle is removed. + const childFailures = await Promise.all(childDisposals.map(async (disposal) => { + try { + await disposal + return undefined + } catch (error: unknown) { + return error } - // Quiesce before the checkpoint: a turn still running would keep - // appending events the flush cannot cover, and a slow flush would let - // model and tool work continue for the whole shutdown. - activation.handle.agent.cancel({ kind: 'parent' }) - await activation.handle.agent.whenIdle() - const durability = await this.checkpoint(activation) - failure ??= durability - // Capture the child-dependent edge data while the child is still live: - // handle disposal unregisters it, and consumers read its log and scope. - activation.observer.capture(activation.handle.agent) + })) + const reasons = childFailures.filter(reason => reason !== undefined) + if (reasons.length > 0) { + failure = new SubagentError( + `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, + 'ACTIVATION_TEARDOWN_FAILED', + ) + } + // Quiesce before the checkpoint: a turn still running would keep + // appending events the flush cannot cover. + await idle + const durability = await this.checkpoint(activation) + failure ??= durability + // Capture the child-dependent edge data while the child is still live: + // handle disposal unregisters it, and consumers read its log and scope. + activation.observer.capture(activation.handle.agent) + } catch (error: unknown) { + failure ??= new SubagentError( + `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + 'ACTIVATION_TEARDOWN_FAILED', + { cause: error }, + ) + } finally { + try { + await activation.handle.dispose() } catch (error: unknown) { failure ??= new SubagentError( - `subagent "${childId}" activation teardown failed: ${errorChain(error)}`, + `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }, ) } finally { - try { - await activation.handle.dispose() - } catch (error: unknown) { - failure ??= new SubagentError( - `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, - 'ACTIVATION_TEARDOWN_FAILED', - { cause: error }, - ) - } finally { - // Only now is the Activation gone: keeping the entry until disposal - // settles makes a racing delivery wait for release rather than - // cold-resume into the still-registered agent. - this.activations.delete(childId) - // Release ownership even on failure: a retained failed child would - // pin its ancestors in `waiting` forever. - this.releaseOwnership(childId) - // Emit once the disposal outcome is known, so a rejecting scoped - // cleanup cannot be reported as a successful epoch. - activation.observer.settle(failure) - } + // Only now is the Activation gone: keeping the entry until disposal + // settles makes a racing delivery wait for release rather than + // cold-resume into the still-registered agent. + this.activations.delete(childId) + // Release ownership even on failure: a retained failed child would pin + // its ancestors in `waiting` forever. + this.releaseOwnership(childId) + // Emit once the disposal outcome is known, so a rejecting scoped cleanup + // cannot be reported as a successful epoch. + activation.observer.settle(failure) } - if (failure !== undefined) throw failure - })()) + } + if (failure !== undefined) throw failure } /** diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index cf07d4aff1..81d88f9d29 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -215,6 +215,23 @@ export class SubagentService extends Service { await manager.drain() } + /** + * Close continuable admission below exact live parent Agents, stop only their + * visible descendant Activations synchronously, then await admitted scoped + * materializations and release those forests child-first. The scoped cutoff + * lasts until each exact parent leaves the registry; unrelated parent trees + * remain live. + * @param parents - exact host-owned parent Agents entering teardown. + * @returns once every retained descendant Activation released its `AgentHandle`. + * @throws an aggregate error after all scoped branches settle when any failed. + */ + async drainContinuableDescendants(parents: readonly Agent[]): Promise { + const manager = this.continuations + // Absent continuation services means nothing was ever materialized. + if (manager === undefined) return + await manager.drainDescendants(parents) + } + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 79a1376b54..9c02a7ec97 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -663,6 +663,199 @@ describe('continuable durability and teardown', () => { expect(loaded.meta.id).toBe(started.childId) }) + it('drains one parent forest without disabling a sibling parent forest', async () => { + const releaseTarget = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const releaseSibling = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('target child'), gate: releaseTarget.promise }, + { chunks: textResponse('sibling child'), gate: releaseSibling.promise }, + { chunks: textResponse('target grandchild'), gate: releaseGrandchild.promise }, + { chunks: textResponse('sibling follow-up') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const siblingParent = ctx.agentLoop.create( + SessionId('sibling-parent'), + { provider: 'mock', model: 'mock' }, + ) + const target = await ctx.subagents.startContinuable(startSpec(parent)) + const sibling = await ctx.subagents.startContinuable(startSpec(siblingParent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const targetChild = ctx.agents.get(target.childId)! + const siblingChild = ctx.agents.get(sibling.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(targetChild)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + const convergedDrain = ctx.subagents.drainContinuableDescendants([parent]) + + // The scoped cutoff stops only the selected forest. The sibling child stays + // resident and can accept later work while target cleanup is still blocked. + expect(cancellations).toEqual([target.childId, grandchild.childId]) + expect(ctx.agents.get(target.childId)).toBe(targetChild) + expect(ctx.agents.get(grandchild.childId)).toBeDefined() + expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) + await expect(followup(ctx, siblingParent, sibling.childId, message('still live'))) + .resolves.toBeTypeOf('string') + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + await expect(followup(ctx, parent, target.childId, message('too late'))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseTarget.resolve(undefined) + releaseGrandchild.resolve(undefined) + await Promise.all([drained, convergedDrain]) + expect(ctx.agents.get(target.childId)).toBeUndefined() + expect(ctx.agents.get(grandchild.childId)).toBeUndefined() + expect(ctx.agents.get(sibling.childId)).toBe(siblingChild) + // The exact root remains closed until its host disposes it, even after all + // current descendants are gone. + await expect(ctx.subagents.startContinuable(startSpec(parent))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseSibling.resolve(undefined) + await waitNoActivation(ctx, sibling.childId) + }) + + it('retains a continuable root while draining only its descendants', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([child]) + + expect(cancellations).toEqual([grandchild.childId]) + expect(ctx.agents.get(started.childId)).toBe(child) + releaseGrandchild.resolve(undefined) + await drained + expect(ctx.agents.get(grandchild.childId)).toBeUndefined() + expect(ctx.agents.get(started.childId)).toBe(child) + await expect(ctx.subagents.startContinuable(startSpec(child))) + .rejects.toMatchObject({ code: 'DRAINING' }) + + releaseChild.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + + it('finds scoped descendants after an intermediate one-shot Agent leaves the registry', async () => { + const releaseIntermediate = Promise.withResolvers() + const releaseDescendant = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('one-shot'), gate: releaseIntermediate.promise }, + { chunks: textResponse('continuable descendant'), gate: releaseDescendant.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const run = await ctx.subagents.start('spawn', { + prompt: message('one-shot task'), + parent, + signal: testSignal, + }) + const intermediate = run.localAgent + expect(intermediate).toBeDefined() + if (intermediate === undefined) throw new Error('spawn must publish a local Agent') + const descendant = await ctx.subagents.startContinuable(startSpec(intermediate)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + + const intermediateId = intermediate.id + const disposingIntermediate = run.dispose() + releaseIntermediate.resolve(undefined) + await disposingIntermediate + expect(ctx.agents.get(intermediateId)).toBeUndefined() + expect(ctx.agents.get(descendant.childId)).toBeDefined() + const cancellations: SessionId[] = [] + ctx.on('agent/cancel-requested', (agent) => { cancellations.push(agent.id) }) + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + + expect(cancellations).toEqual([descendant.childId]) + releaseDescendant.resolve(undefined) + await drained + expect(ctx.agents.get(descendant.childId)).toBeUndefined() + }) + + it('awaits and rolls back an admitted materialization below a scoped root', async () => { + const { ctx, parent } = await setup([]) + const manager = (ctx.subagents as unknown as { + continuations: { ownerCtx: Context } + }).continuations + const agents = manager.ownerCtx.agents + const create = agents.create.bind(agents) + const published = Promise.withResolvers() + const releaseMaterialization = Promise.withResolvers() + const createSpy = vi.spyOn(agents, 'create').mockImplementation(async (options) => { + const handle = await create(options) + published.resolve(handle.agent.id) + await releaseMaterialization.promise + return handle + }) + + try { + const starting = ctx.subagents.startContinuable(startSpec(parent)) + const childId = await published.promise + let drainResolved = false + const drained = ctx.subagents.drainContinuableDescendants([parent]).then(() => { + drainResolved = true + }) + await Promise.resolve() + expect(drainResolved).toBe(false) + + releaseMaterialization.resolve(undefined) + await expect(starting).rejects.toMatchObject({ code: 'DRAINING' }) + await drained + expect(ctx.agents.get(childId)).toBeUndefined() + } finally { + createSpy.mockRestore() + } + }) + + it('ignores a stale scoped root without disabling its live same-id Agent', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const stale = { ...parent, id: parent.id } as unknown as Agent + + await ctx.subagents.drainContinuableDescendants([stale]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + + await waitNoActivation(ctx, started.childId) + }) + + it('reports a scoped teardown failure after releasing the selected branch', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('target child'), gate: hold.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const manager = (ctx.subagents as unknown as { + continuations: { activations: Map Promise } }> } + }).continuations + const activation = manager.activations.get(started.childId)! + const realDispose = activation.handle.dispose.bind(activation.handle) + activation.handle.dispose = async () => { + await realDispose() + throw new Error('scoped child reap failed') + } + + const drained = ctx.subagents.drainContinuableDescendants([parent]) + hold.resolve(undefined) + + await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + it('rejects new materialization and delivery once draining begins', async () => { const { ctx, parent } = await setup([textResponse('done')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 64c510bf40..0beb075237 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,10 +119,11 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) - it('drains continuable activations as a no-op when no manager was bound', async () => { + it('treats global and scoped drains as no-ops when no manager was bound', async () => { const { subagents } = await service() // Without `ctx.agents` no manager exists, so nothing was ever materialized. await expect(subagents.drainContinuable()).resolves.toBeUndefined() + await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) it('rejects continuable operations when their runtime services are absent', async () => { From f4a65a34e63c7debf01957baea8973e57a591ddf Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 31 Jul 2026 14:24:35 +0800 Subject: [PATCH 23/66] cleanup(subagent): hide manager-wide continuation drain --- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 2 +- ...8-continuable-subagent-conversations.zh.md | 2 +- docs/cordis-catalog/services.md | 9 ---- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 -- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 1 - packages/subagent/subagent/README.zh.md | 1 - packages/subagent/subagent/src/index.ts | 14 ----- .../subagent/tests/continuation.spec.ts | 54 ++++++++++--------- .../subagent/subagent/tests/service.spec.ts | 4 +- 14 files changed, 41 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 87101c479a..ee9124f314 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -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: 8e867254a726c57200936cff6c83831abb4c66ad -2026-07-28-continuable-subagent-conversations.zh.md: 3383a0b8fd7b17ad40c3a03f32a78df058afb120 +2026-07-28-continuable-subagent-conversations.md: 43abcd88d172104ee5bc55e5a99b4e0306d6dd12 +2026-07-28-continuable-subagent-conversations.zh.md: 57d09e176362c05791cdd6317d95b6a3f5082f40 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index 8e867254a7..43abcd88d1 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -107,7 +107,7 @@ Child release occurs only after the child Agent is quiescent, every child of tha Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free proposal deliberately does not add. -Top-level teardown is host-owned rather than represented as another Activation. Manager unload uses `drainContinuable()` to close manager-wide admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents instead uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only the global drain precedes manager-scope disposal. +Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal. 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. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 3383a0b8fd..57d09e1763 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -107,7 +107,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup( 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 提案特意不增加该机制。 -顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载使用 `drainContinuable()` 同步关闭管理器全局准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主则使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有全局 drain 会先于管理器作用域 dispose。 +顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。 activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d574b8b1dd..bcba4fe94f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,15 +1980,6 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise -/** - * 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. - * @returns once every live Activation released its `AgentHandle`. - * @throws an aggregate error after all branches settle when any failed. - */ -async drainContinuable(): Promise - /** * Close continuable admission below exact live parent Agents, stop only their * visible descendant Activations synchronously, then await admitted scoped diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 1e0a7546d3..6bd9d32400 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -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: eff78bae6fcf7440cce66f122771ff623a5eb3c7 -subagent.zh.md: a898f438c257db05a3d84e6318ce23e1f366e193 +subagent.md: 379bc9fdd5ff14f9d516d3ddcf37a353f5318026 +subagent.zh.md: 3cd43deadba095e67e6a0dd3b483ea676206fbbf diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index eff78bae6f..379bc9fdd5 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -129,7 +129,7 @@ For both operations the caller signal owns lookup, materialization, and admissio Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens 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. -Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. `drainContinuable()` closes manager-wide admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. +Only `ctx.sessions.flush(session) === true` confirms durability; `false` or rejection reports `DURABILITY_FAILED`. Either way the manager still disposes the handle and releases ownership, because retaining a failed child would permanently pin its ancestors in `waiting` — the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index a898f438c2..3cd43deadb 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -129,7 +129,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、最终的持久性检查点结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 -只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。`drainContinuable()` 会关闭管理器全局准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 +只有 `ctx.sessions.flush(session) === true` 才确认持久性;`false` 或 rejection 会报告 `DURABILITY_FAILED`。无论哪种情况,管理器仍会 dispose 该 handle 并释放所有权,因为保留一个失败的子 agent 会将其祖先永久钉在 `waiting`——此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0990ff339a..d8ce1b4150 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,10 +892,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', 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', - jsDoc: '/**\n * Close continuable admission synchronously, then dispose every live\n * Activation forest child-first. A host calls this before disposing top-level\n * agents so no descendant outlives the runtime that owns its teardown.\n * @returns once every live Activation released its `AgentHandle`.\n * @throws an aggregate error after all branches settle when any failed.\n */', - }, { signature: 'async drainContinuableDescendants(parents: readonly Agent[]): Promise', jsDoc: '/**\n * Close continuable admission below exact live parent Agents, stop only their\n * visible descendant Activations synchronously, then await admitted scoped\n * materializations and release those forests child-first. The scoped cutoff\n * lasts until each exact parent leaves the registry; unrelated parent trees\n * remain live.\n * @param parents - exact host-owned parent Agents entering teardown.\n * @returns once every retained descendant Activation released its `AgentHandle`.\n * @throws an aggregate error after all scoped branches settle when any failed.\n */', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 4a0496100e..83088e9c21 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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: 6fab6859e2c15fdb1ded023642cbc593e0457384 -README.zh.md: 1f59807a545dcb1fafbac3f301746c7217d15f3a +README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e +README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6fab6859e2..cea62b90a4 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,6 @@ Multiple providers may coexist under different names. This lets a deployment exp | `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(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, await every materialization that already passed admission through publication or rollback, then dispose the stable 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. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | `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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 1f59807a54..73256ef503 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,7 +31,6 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `drainContinuable()` | 同步关闭可继续准入,等待每个已经通过准入的物化过程完成发布或回滚,然后按 child-first 顺序 dispose 稳定的在线 Activation 森林。host 会在 dispose 顶层 agent 之前调用它,使任何后代都不会比拥有其拆卸职责的运行时存活更久。任一分支失败时,会在所有分支结算后抛出聚合错误。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | `SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 81d88f9d29..91cda659c8 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -201,20 +201,6 @@ export class SubagentService extends Service { return this.requireContinuations().followup(parent, childId, content, options) } - /** - * 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. - * @returns once every live Activation released its `AgentHandle`. - * @throws an aggregate error after all branches settle when any failed. - */ - async drainContinuable(): Promise { - const manager = this.continuations - // Absent continuation services means nothing was ever materialized. - if (manager === undefined) return - await manager.drain() - } - /** * Close continuable admission below exact live parent Agents, stop only their * visible descendant Activations synchronously, then await admitted scoped diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9c02a7ec97..dc899f778c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -122,6 +122,18 @@ function followup( }) } +/** + * Exercise manager-wide teardown through the package-private owner rather than + * adding the irreversible operation to the public service contract. + */ +function drainManager(ctx: Context): Promise { + const manager = (ctx.subagents as unknown as { + continuations?: { drain(): Promise } + }).continuations + if (manager === undefined) throw new Error('expected a bound continuation manager') + return manager.drain() +} + /** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ async function waitNoActivation(ctx: Context, childId: SessionId): Promise { await vi.waitFor(() => { @@ -245,7 +257,7 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')]) }) expect(ends).toEqual([]) - await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() + await expect(drainManager(ctx)).resolves.toBeUndefined() }) it('rejects a continuable child that would exceed the configured depth cap', async () => { @@ -283,7 +295,7 @@ describe('SubagentService.startContinuable', () => { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', }) - await ctx.subagents.drainContinuable() + await drainManager(ctx) }) it('records a declared tool filter in the descriptor', async () => { @@ -316,7 +328,7 @@ describe('SubagentService.startContinuable', () => { provider: 'spawn', toolFilter: { deny: ['noop'] }, }) - await ctx.subagents.drainContinuable() + await drainManager(ctx) }) it('cold-resumes without inventing a model route the descriptor never declared', async () => { @@ -341,7 +353,7 @@ describe('SubagentService.startContinuable', () => { }) expect(resumed.options.provider).toBeUndefined() expect(resumed.options.model).toBeUndefined() - await fresh.subagents.drainContinuable() + await drainManager(fresh) }) it('numbers the descriptor turn after an inherited fork prefix', async () => { @@ -649,7 +661,7 @@ describe('continuable durability and teardown', () => { const disposals: SessionId[] = [] ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) await drained @@ -861,7 +873,7 @@ describe('continuable durability and teardown', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) - await ctx.subagents.drainContinuable() + await drainManager(ctx) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -873,7 +885,7 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const drains: Promise[] = [] const accepted: MessageId[] = [] - ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) }) + ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) @@ -890,7 +902,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] ctx.on('agent/created', (child) => { if (child === parent) return - const draining = ctx.subagents.drainContinuable().then(() => { order.push('drain') }) + const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) ctx.on('agent/disposed', (child) => { @@ -926,7 +938,7 @@ describe('continuable durability and teardown', () => { // Let the child-lock operation reach the live admission cutoff. Admission // and inbox submission must then complete in one synchronous span. await Promise.resolve() - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(delivery).resolves.toBeTypeOf('string') @@ -943,7 +955,7 @@ describe('continuable durability and teardown', () => { // Accepted into the inbox, but this queued turn never opens. await followup(ctx, parent, started.childId, message('never logged')) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained await waitNoActivation(ctx, started.childId) @@ -1024,7 +1036,7 @@ describe('continuable review regressions', () => { expect(activation.accepted.size).toBe(0) child.followup = realFollowup - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained }) @@ -1131,7 +1143,7 @@ describe('continuable review regressions', () => { throw new Error('scoped cleanup failed') } - await expect(ctx.subagents.drainContinuable()).rejects.toThrow() + await expect(drainManager(ctx)).rejects.toThrow() await vi.waitFor(() => { expect(ends).toHaveLength(1) }) // Emitting before disposal would have reported this failed epoch as success. expect(ends[0]!.stopReason).toBe('error') @@ -1153,7 +1165,7 @@ describe('continuable review regressions', () => { const activation = manager.activations.get(started.childId)! activation.observer.capture = () => { throw new Error('capture failed') } - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) await vi.waitFor(() => { expect(ends).toHaveLength(1) }) @@ -1177,7 +1189,7 @@ describe('continuable review regressions', () => { }) child.ctx.on('agent/cancel-requested', () => { order.push('cancel') }) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained @@ -1196,7 +1208,7 @@ describe('continuable review regressions', () => { // Activation must still reach settlement instead of waiting on that id. await followup(ctx, parent, started.childId, message('discarded')) - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await drained @@ -1456,7 +1468,7 @@ describe('continuable errors', () => { }) // Begin the parent Activation's teardown, then try to give it a child. - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) await expect(ctx.subagents.startContinuable(startSpec(child))) .rejects.toMatchObject({ code: 'DRAINING' }) hold.resolve(undefined) @@ -1490,7 +1502,7 @@ describe('continuable errors', () => { throw new Error('grandchild reap failed') } - const drained = ctx.subagents.drainContinuable() + const drained = drainManager(ctx) hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) // The other branch still released, and durable sessions survive. @@ -1552,14 +1564,6 @@ describe('continuable errors', () => { await waitNoActivation(ctx, started.childId) }) - it('drains without continuation services as a no-op', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SubagentService) - // No `ctx.agents`, so no manager was ever bound and nothing was materialized. - await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined() - }) - it('unloading the manager drains its live activations', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 0beb075237..44c230fdc1 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -119,10 +119,10 @@ describe('SubagentService', () => { expect('resume' in provider).toBe(false) }) - it('treats global and scoped drains as no-ops when no manager was bound', async () => { + it('does not expose manager teardown and treats a scoped drain as a no-op when no manager was bound', async () => { const { subagents } = await service() // Without `ctx.agents` no manager exists, so nothing was ever materialized. - await expect(subagents.drainContinuable()).resolves.toBeUndefined() + expect('drainContinuable' in subagents).toBe(false) await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) From 9cd3c57b751a7df6f4d97813186e7ccc139a5e9f Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:04:02 +0800 Subject: [PATCH 24/66] test(subagent): align continuation fixtures with session API --- .../subagent-fork/tests/subagent-fork.spec.ts | 2 +- .../subagent/tests/continuation.spec.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 924ed2df62..a11a5fac86 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -226,7 +226,7 @@ describe('dsh-subagent-fork', () => { expect(fresh.seed).toBeUndefined() // Complete one parent turn, then the prefix is captured once at creation. - parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }) + parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await parent.whenIdle() const seeded = await provider.prepareContinuable!({ sessionId: SessionId('continuable-seeded'), diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dc899f778c..2369e0bb21 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,7 +12,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -356,24 +356,26 @@ describe('SubagentService.startContinuable', () => { await drainManager(fresh) }) - it('numbers the descriptor turn after an inherited fork prefix', async () => { + it('continues turn numbering after an inherited fork prefix and pre-turn descriptor', async () => { const { ctx, parent } = await setup([ textResponse('parent turn'), textResponse('forked child'), ]) // Complete one parent turn so fork has a prefix to contribute. - parent.followup({ content: message('parent work'), source: { kind: 'user' } }) + parent.followup(createUserMessage({ content: message('parent work'), source: { kind: 'user' } })) await parent.whenIdle() const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const descriptorTurn = loaded.events.find(event => event.type === 'turn/start' - && event.data.trigger.kind === 'subagent-descriptor') - // The seeded descriptor turn continues the inherited numbering rather than - // restarting at 1, so the replayed child log stays balanced. - expect(descriptorTurn?.type === 'turn/start' && descriptorTurn.data.turn).toBe(2) + const descriptorIndex = loaded.events.findIndex(event => event.type === 'subagent/descriptor') + const childTurn = loaded.events.slice(descriptorIndex + 1) + .find(event => event.type === 'turn/start') + // The first child turn after the descriptor continues the inherited prefix + // rather than restarting at 1, so the replayed child log stays balanced. + expect(descriptorIndex).toBeGreaterThanOrEqual(0) + expect(childTurn?.type === 'turn/start' && childTurn.data.turn).toBe(2) expect(loaded.meta.seedLength).toBeGreaterThan(0) }) From 4d0a24d8ed13d081c662d500dab7fc28676dc55b Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:44:36 +0800 Subject: [PATCH 25/66] test(subagent): preserve durability failure after rebase --- .../fixtures/subagent-durability-failure.ts | 2 +- .../subagent-continuable/session.1.jsonl | 63 ++++++----- .../subagent-continuable/session.jsonl | 106 +++++++++--------- 3 files changed, 87 insertions(+), 84 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 8352dac005..5499936667 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -22,7 +22,7 @@ export const inject = ['sessionPersistence', 'subagents'] const PLACEHOLDER_CHILD_ID = '33333333-3333-4333-8333-333333333333' const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' /** The child continuation turn whose durability checkpoint is forced to fail. */ -const FAILED_CHECKPOINT_TURN = 4 +const FAILED_CHECKPOINT_TURN = 3 /** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ export function apply(ctx: Context): void { diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index a5220ed410..653f212a06 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,31 +1,32 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"/tmp/subagent-continuable","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"subagent-descriptor"}}} -{"type":"subagent/descriptor","seq":1,"time":1789000000002,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek","agentModel":"deepseek-v4-flash"}} -{"type":"turn/end","seq":2,"time":1789000000003,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":3,"time":1789000000004,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":4,"time":1789000000005,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1789000000005,"data":{"title":"Reply with exactly the word","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1789000000006,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":7,"time":1789000000007,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1789000000008,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":9,"time":1789000000009,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":10,"time":1789000000010,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":11,"time":1789000000011,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1789000000012,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1789000000013,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1789000000014,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":15,"time":1789000000015,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":16,"time":1789000000016,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":17,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} -{"type":"step/start","seq":18,"time":1789000000018,"data":{"turn":3,"step":1}} -{"type":"assistant/chunk","seq":19,"time":1785394678743,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"time":1789000000020,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":21,"time":1789000000021,"data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":22,"time":1789000000022,"data":{"turn":3,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1789000000023,"data":{"turn":3,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":1785394678743,"data":{"turn":3,"step":1,"content":[{"type":"text","text":"SECOND_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":1785394678743,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":26,"time":1785394678743,"data":{"turn":3,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":27,"time":1785394678756,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} -{"type":"user/message","seq":28,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}},"surfaceOp":"append"} -{"type":"turn/end","seq":29,"time":1785394678762,"data":{"turn":4,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} +{"type":"turn/start","seq":2,"time":1785544945199,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":3,"time":1785544945199,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"cd28c816-821e-412c-bc7f-404bbb599641"},"surfaceOp":"append"} +{"type":"session/title","seq":4,"time":1785544945199,"data":{"title":"Reply with exactly the word","messageSeqs":[3],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":5,"time":1789000000005,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"2ea12eb1-e86f-447a-8574-63f2d819c689"},"surfaceOp":"append"} +{"type":"step/start","seq":6,"time":1785544945227,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":7,"time":1785544945227,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785544945227,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1789000000013,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"99ab55a3-f42f-4816-8fff-3b3bcb15fa6b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1789000000014,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1789000000015,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":17,"time":1789000000016,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":18,"time":1789000000017,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"70a11623-f9c9-43d1-bad6-9bf45d19dd90"},"surfaceOp":"append"} +{"type":"step/start","seq":19,"time":1789000000018,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":20,"time":1785394678743,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1789000000020,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":22,"time":1789000000021,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":23,"time":1789000000022,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1785394678743,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"86c9fc2b-900b-4a84-9089-dd4b8ed3d2d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785394678743,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":27,"time":1785394678743,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":28,"time":1785394678756,"data":{"turn":3,"trigger":{"kind":"message","source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"}}}} +{"type":"user/message","seq":29,"time":1785394678756,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"c3a91e09-99fc-4303-92e4-d8e89cb767f4"},"surfaceOp":"append"} +{"type":"turn/end","seq":30,"time":1785545035946,"data":{"turn":3,"reason":{"kind":"error","step":1,"message":"snapshot disk full"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 4dff6a044a..4d0602e8df 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -1,54 +1,56 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"/tmp/subagent-continuable","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1789000000001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1789000000002,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"9472efc7-dd29-439f-8387-9b2dee43cd33"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1789000000003,"data":{"title":"Follow these steps exactly, then","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1789000000004,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1789000000005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1789000000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} -{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} -{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1789000000011,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1789000000012,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} -{"type":"tool/result","seq":12,"time":1789000000013,"data":{"turn":1,"step":1,"callId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":1789000000014,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":1789000000015,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":15,"time":1785394678688,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1789000000017,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} -{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1785394678689,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1785394678689,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} -{"type":"tool/result","seq":22,"time":1785394678701,"data":{"turn":1,"step":2,"callId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1785394678701,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":24,"time":1785394678713,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":25,"time":1785394678718,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785394678719,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} -{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785394678719,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785394678719,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} -{"type":"tool/result","seq":32,"time":1785394678733,"data":{"turn":1,"step":3,"callId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1785394678733,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":34,"time":1785394678746,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":35,"time":1785394678752,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} -{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785394678753,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785394678753,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} -{"type":"tool/result","seq":42,"time":1785394678765,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true,"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[41],"surfaceOp":"append"} -{"type":"step/end","seq":43,"time":1785394678765,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":44,"time":1785394678774,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":45,"time":1785394678778,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":46,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":50,"time":1785394678779,"data":{"turn":1,"step":5,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1785394678779,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":52,"time":1785394678779,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1785544945178,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"d28c0ecc-be25-4d19-9834-ad72889ddaa3"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785544945178,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785544945179,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":1785544945179,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1789000000009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":10,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":1785544945188,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1785544945188,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3a4667fb-352d-4ee7-ab80-42cf1dd6fb35"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1785544945188,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":14,"time":1785544945199,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3567aec2-7919-4813-a15d-c5e9021f6968"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1785544945199,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1785544945207,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1789000000018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1789000000019,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_1","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}} +{"type":"assistant/chunk","seq":19,"time":1789000000020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}}}} +{"type":"assistant/chunk","seq":20,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":21,"time":1785544945212,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1785544945212,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"beccc09f-a7ad-4537-ba2d-756961723dd4"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785544945212,"data":{"turn":1,"step":2,"callId":"call_followup_1","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly SECOND_OK.\"}"}} +{"type":"tool/result","seq":24,"time":1785544945224,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_followup_1"},"content":[{"type":"tool-result","toolCallId":"call_followup_1","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"fd85eacb-71f4-4a33-a512-b2e0c3040f65"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785544945224,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":26,"time":1785544945236,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":27,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000029,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_2","name":"send_message","argumentsDelta":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}} +{"type":"assistant/chunk","seq":29,"time":1789000000030,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}}}} +{"type":"assistant/chunk","seq":30,"time":1785544945241,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":31,"time":1785544945242,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":32,"time":1785544945242,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b3e3797-a438-4751-8328-430cb4dc8689"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"tool/call","seq":33,"time":1785544945242,"data":{"turn":1,"step":3,"callId":"call_followup_2","name":"send_message","arguments":"{\"subagent_id\": \"33333333-3333-4333-8333-333333333333\", \"message\": \"Now reply with exactly THIRD_OK.\"}"}} +{"type":"tool/result","seq":34,"time":1785544945255,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_followup_2"},"content":[{"type":"tool-result","toolCallId":"call_followup_2","content":[{"type":"text","text":"message queued as the next turn for subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"cfb87156-ab7e-4641-a99b-245215621b90"}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785544945255,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":36,"time":1785544945267,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":37,"time":1785394678753,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1789000000039,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_followup_unknown","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":39,"time":1789000000040,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":40,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":41,"time":1785544945273,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":42,"time":1785544945273,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"52f1ebed-7577-4007-a07a-00f6a603c2f0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"} +{"type":"tool/call","seq":43,"time":1785544945273,"data":{"turn":1,"step":4,"callId":"call_followup_unknown","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":44,"time":1785544945285,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_followup_unknown"},"content":[{"type":"tool-result","toolCallId":"call_followup_unknown","content":[{"type":"text","text":"Error: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable"}],"isError":true}],"role":"user","id":"eaf26a9c-d339-4fa3-900a-9e47d23cccaf"},"error":{"name":"SubagentError","code":"NOT_RESUMABLE"}},"sourceEventSeqs":[43],"surfaceOp":"append"} +{"type":"step/end","seq":45,"time":1785544945285,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":46,"time":1785544945297,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":47,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":48,"time":1785394678779,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":49,"time":1789000000050,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":50,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":51,"time":1785544945303,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":52,"time":1785544945303,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fe4182fd-2de4-4e8d-9770-cb221b2b416a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785544945303,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":54,"time":1785544945303,"data":{"turn":1,"reason":{"kind":"completed"}}} From 7b3920801b8b891d2b23240f16a92ae37703dc35 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 09:17:12 +0800 Subject: [PATCH 26/66] fix(subagent): deduplicate activation teardown --- .../subagent/subagent/src/continuation.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 2bd89607f8..03b4c34a31 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -404,22 +404,7 @@ export class SubagentContinuationManager { for (const child of activation.ownedChildren) owned.add(child) } const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId)) - const failures = await Promise.all(roots.map(async (activation) => { - try { - await this.dispose(activation) - return undefined - } catch (error: unknown) { - return error - } - })) - const reasons = failures.filter(failure => failure !== undefined) - if (reasons.length > 0) { - throw new SubagentError( - `continuable subagent teardown failed for ${reasons.length} activation(s): ` - + reasons.map(reason => errorChain(reason)).join('; '), - 'ACTIVATION_TEARDOWN_FAILED', - ) - } + await this.disposeRoots(roots, 'activation(s)') } /** @@ -481,7 +466,15 @@ export class SubagentContinuationManager { } await Promise.all(materializations.map(materialization => materialization.settled)) - const failures = await Promise.all(targetRoots.map(async (activation) => { + await this.disposeRoots(targetRoots, 'scoped activation(s)') + } + + /** Dispose independent roots and report every branch failure after all settle. */ + private async disposeRoots( + roots: readonly Activation[], + failureSubject: 'activation(s)' | 'scoped activation(s)', + ): Promise { + const failures = await Promise.all(roots.map(async (activation) => { try { await this.dispose(activation) return undefined @@ -492,7 +485,7 @@ export class SubagentContinuationManager { const reasons = failures.filter(failure => failure !== undefined) if (reasons.length > 0) { throw new SubagentError( - `continuable subagent teardown failed for ${reasons.length} scoped activation(s): ` + `continuable subagent teardown failed for ${reasons.length} ${failureSubject}: ` + reasons.map(reason => errorChain(reason)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', ) From fc59b63c8c4ab4fe797896cc5b4783d774034f7d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:18:04 +0800 Subject: [PATCH 27/66] docs: propose durable subagent catalog --- ...subagent-catalog-and-list-agents.i18n.yaml | 6 ++ ...urable-subagent-catalog-and-list-agents.md | 66 +++++++++++++++++++ ...ble-subagent-catalog-and-list-agents.zh.md | 66 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md create mode 100644 .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml new file mode 100644 index 0000000000..bf9a1ca9ff --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-durable-subagent-catalog-and-list-agents.md: 8ffc83e26121d7d1b542e549235290c226739a94 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 5011fc05537ef5e8a3310c5b191226e46c98f40d diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md new file mode 100644 index 0000000000..8ffc83e261 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -0,0 +1,66 @@ +# Agent Note: Durable subagent catalog and list_agents + +Status: proposed + +English | [中文](2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) + +## Problem + +Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable child-handle and activation design is owned by [continuable background subagents](2026-07-21-continuable-background-subagents.md); this note owns enumeration and its model-facing query. + +Enumeration must cross-check immutable session lineage, descriptor validity, and process-local activation state without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. + +## Proposal + +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-handle contract. `SubagentControlService.listChildren(parent)` must: + +- find materialized session headers whose `parentSession` identifies the caller's session; +- load and validate each candidate's `subagent/descriptor` event without activating the child; +- exclude sessions that are one-shot, corrupt, unsupported, missing, or not direct children; +- overlay the process-local Task association without treating it as durable state. + +Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. + +### Enumeration decision + +The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and calls `load()` only for those direct-child candidates to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports it as unavailable, while persistence listing omits it. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. + +This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. + +Listing adds no session event and no surface node. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. + +### `list_agents` contract + +`SubagentControlService.listChildren(parent)` returns only durable direct children that carry a valid continuable descriptor, then overlays the process-local Task association. The model-facing `list_agents` tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` and reports two operational states: + +- `running`: a non-terminal Task-backed activation exists, including startup and settlement before Task terminal publication; +- `resumable`: a valid durable descriptor exists and no activation is associated. + +These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Corrupt, unsupported-version, wrong-parent, or missing-child descriptors fail explicitly rather than being silently advertised as resumable. + +The first version is read-only and has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. + +## Alternatives considered + +**Fold listing into the activation RFC.** Descriptor-by-id persistence and cold resume do not require parent-to-child enumeration. Keeping the query separate lets `send_message` land without taking on listing states, scanning performance, or deletion behavior. + +**List every persisted session whose header names the parent.** `parentSession` proves lineage but does not prove that the child is continuable. Listing must also load and validate the descriptor. + +**Use the live Agent registry as the catalog.** Runs are deliberately disposed after every Task, and registry state disappears on restart. It cannot support durable discovery. + +**Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. + +## Acceptance criteria + +- Enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only children whose persisted descriptor satisfies the durable child-handle contract. +- Listing loads no Agent, appends no session event, and returns the same children from compacted and uncompacted logs. +- `list_agents` returns only valid direct continuable children and reports `running` or `resumable`, with no pass-through runtime status. +- Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. +- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, wrong-parent child, and stale derived-index entry are never advertised as resumable. +- Keyless tests cover fresh and compacted discovery, restart, wrong-parent access, unsupported descriptors, scan behavior, and stale-index fallback. The model-facing tool has runnable snapshot coverage. + +## Risks + +- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, corruption, and fallback behavior. +- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md new file mode 100644 index 0000000000..5011fc0553 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -0,0 +1,66 @@ +# Agent Note(agent 决策记录):持久化 subagent 目录与 list_agents + +Status: proposed + +[English](2026-07-22-durable-subagent-catalog-and-list-agents.md) | 中文 + +## 问题 + +可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续的后台 subagent](2026-07-21-continuable-background-subagents.md)负责持久化 child handle 与激活设计;本记录负责枚举及其面向模型的查询。 + +枚举必须交叉核对不可变的会话谱系、描述符有效性与进程内激活状态,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 + +## 提案 + +将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child handle 契约、单独评审的功能。`SubagentControlService.listChildren(parent)` 必须: + +- 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; +- 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; +- 排除一次性、损坏、不受支持、缺失或并非直接 child 的会话; +- 叠加进程内 Task 关联,但不将该关联视为持久化状态。 + +描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 + +### 枚举决策 + +第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,并且只对这些直接 child 候选调用 `load()` 来归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告该 id 不可用,持久化列表则不会列出它。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 + +这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 + +列表查询不添加会话事件或 surface 节点。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 + +### `list_agents` 契约 + +`SubagentControlService.listChildren(parent)` 只返回具有有效可继续描述符的持久化直接 child,再叠加进程内 Task 关联。面向模型的 `list_agents` 工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,并报告两种操作状态: + +- `running`:存在由非终态 Task 支撑的激活,包括启动阶段和 Task 终态发布前的结算阶段; +- `resumable`:存在有效的持久化描述符,且没有关联任何激活。 + +这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。描述符损坏、版本不受支持、parent 不匹配或 child 缺失时,系统会明确失败,而不会将其静默标记为可恢复。 + +第一版只读,不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 + +## 已考虑的替代方案 + +**将列表查询并入激活 RFC。** 按 id 持久化描述符和从持久化存储恢复无需 parent 到 child 的枚举。保持查询独立,可让 `send_message` 落地时不必同时承担列表状态、扫描性能或删除行为。 + +**枚举 header 中以该 parent 为 parent 的每个持久化会话。** `parentSession` 能证明谱系,却不能证明 child 可继续。列表查询还必须加载并校验描述符。 + +**使用存活的 Agent 注册表作为目录。** 系统会在每个 Task 结束后有意 dispose 对应 run,而且注册表状态会在重启时消失,因此无法支持持久化发现。 + +**持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 + +## 验收标准 + +- 枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的 child。 +- 列表查询不加载 Agent、不追加会话事件,并从经过压缩和未经压缩的日志返回相同的 child。 +- `list_agents` 只返回有效的直接可继续 child,并报告 `running` 或 `resumable`,不直接透传运行时状态。 +- 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 +- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本、parent 不匹配的 child 和陈旧的派生索引条目绝不会被标记为可恢复。 +- 无密钥测试覆盖压缩前后的发现、重启、错误 parent 访问、不受支持的描述符、扫描行为和陈旧索引回退。面向模型的工具具有可运行的快照覆盖。 + +## 风险 + +- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、损坏处理和回退行为。 +- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 From ceaef2c3d0b4fc8535d1e60bee4d04d976dbb67f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 10:16:15 +0800 Subject: [PATCH 28/66] Clarify durable subagent catalog and list_agents behavior --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 43 ++++++++++++------- ...ble-subagent-catalog-and-list-agents.zh.md | 43 ++++++++++++------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index bf9a1ca9ff..fee41ed170 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -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 -2026-07-22-durable-subagent-catalog-and-list-agents.md: 8ffc83e26121d7d1b542e549235290c226739a94 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 5011fc05537ef5e8a3310c5b191226e46c98f40d +2026-07-22-durable-subagent-catalog-and-list-agents.md: 47c99ee6171bbb64416eeb497146a8aa11ea6869 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e80e35eddefc050fa49c26cef88df56520eb58f2 diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 8ffc83e261..47c99ee617 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -16,29 +16,31 @@ Treat parent-to-child enumeration and `list_agents` as one separately reviewed f - find materialized session headers whose `parentSession` identifies the caller's session; - load and validate each candidate's `subagent/descriptor` event without activating the child; -- exclude sessions that are one-shot, corrupt, unsupported, missing, or not direct children; -- overlay the process-local Task association without treating it as durable state. +- union those durable candidates with the parent's process-local Task associations, including active children that have not materialized yet; +- omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; +- expose an inactive child as resumable only when its descriptor is valid and its provider is currently registered with `resume?()`; +- return every resulting child in stable `createdAt` ascending, child-id ascending order. Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. ### Enumeration decision -The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and calls `load()` only for those direct-child candidates to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports it as unavailable, while persistence listing omits it. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. +The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and unions those ids with Task associations owned by the parent. An associated child is resolved from the live association and is never passed to `SessionPersistence.load()`; only inactive direct-child candidates are loaded to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports an inactive instance as unavailable, but an active association still appears in `list_agents` as `running`. Once that Task becomes terminal, the child remains discoverable only if its durable descriptor validates. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. -Listing adds no session event and no surface node. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. +`SessionPersistence.load()` may durably repair an interrupted child log by appending synthetic closing events. The first version accepts this existing persistence side effect: `listChildren()` creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. ### `list_agents` contract -`SubagentControlService.listChildren(parent)` returns only durable direct children that carry a valid continuable descriptor, then overlays the process-local Task association. The model-facing `list_agents` tool is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` and reports two operational states: +`SubagentControlService.listChildren(parent)` returns all direct continuable children in the union of durable candidates and active Task associations, plus non-fatal diagnostics for inactive candidates it could not load, validate, or resume. An association records its creation time when the control service allocates the child id; a materialized child uses `SessionHeader.createdAt`. Children are sorted by that `createdAt` ascending, then child id ascending. Diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`; it renders the complete sorted children and diagnostics together, and reports two operational child states: -- `running`: a non-terminal Task-backed activation exists, including startup and settlement before Task terminal publication; -- `resumable`: a valid durable descriptor exists and no activation is associated. +- `running`: a non-terminal Task-backed activation exists, including startup before materialization and settlement before Task terminal publication; +- `resumable`: no activation is associated, a valid durable descriptor exists, and the named provider is currently registered with `resume?()`. -These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Corrupt, unsupported-version, wrong-parent, or missing-child descriptors fail explicitly rather than being silently advertised as resumable. +These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Inactive candidates use three diagnostic reasons: `corrupt` for malformed committed data or descriptor content, `unsupported` for an unknown descriptor version, and `unavailable` when the candidate disappears, another child-specific load fails, or its provider is absent or lacks `resume?()`. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Failure of the initial `SessionPersistence.list()` operation fails the whole call because no candidate set exists. Headers whose `parentSession` names another parent are filtered before descriptor loading and produce no diagnostic. -The first version is read-only and has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. +The first version has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. ## Alternatives considered @@ -50,17 +52,28 @@ The first version is read-only and has no child deletion operation. If later pro **Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. +**Fail the whole listing when one child cannot be loaded.** This makes corruption impossible to overlook, but one damaged sibling removes visibility into every healthy child. Per-child diagnostics preserve discovery while keeping each omission explicit. + +**Add a repair-free descriptor inspection API.** This would make discovery strictly storage-read-only, but expands the persistence seam solely to avoid the interrupted-tail repair that normal session load and eventual resume already require. The first version accepts `load()` semantics and documents the side effect. + +**Paginate or cap the model-facing result.** This bounds one tool result, but makes discovery stateful and can hide older children unless the model follows a cursor. The first version has no arguments and returns the complete stably ordered set; deployments with many durable children accept the corresponding context cost. + ## Acceptance criteria -- Enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only children whose persisted descriptor satisfies the durable child-handle contract. -- Listing loads no Agent, appends no session event, and returns the same children from compacted and uncompacted logs. -- `list_agents` returns only valid direct continuable children and reports `running` or `resumable`, with no pass-through runtime status. +- Durable enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only inactive children whose persisted descriptor satisfies the durable child-handle contract; the final result unions those children with parent-owned active associations. +- Listing loads no Agent and appends no catalog or descriptor event itself, but may trigger `SessionPersistence.load()` interrupted-tail repair for inactive children; an already-associated child is never loaded, and compacted and uncompacted logs return the same children. +- `list_agents` takes no arguments and returns all valid direct continuable children plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. +- Active Task associations appear as `running` even before durable materialization; after Task terminal, the child appears as `resumable` only when its descriptor validates and its currently registered provider implements `resume?()`. +- `list_agents` reports no pass-through runtime status, uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons, and never exposes descriptor contents in a diagnostic. - Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. -- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, wrong-parent child, and stale derived-index entry are never advertised as resumable. -- Keyless tests cover fresh and compacted discovery, restart, wrong-parent access, unsupported descriptors, scan behavior, and stale-index fallback. The model-facing tool has runnable snapshot coverage. +- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, and stale derived-index entry are never advertised as resumable; non-child headers are filtered before load. +- A corrupt, unsupported, disappeared, or unloadable candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic, while failure of the initial persistence listing fails the whole call. +- Keyless tests cover fresh and compacted discovery, active unmaterialized children, transition from running association to durable resume, provider absence, stable ordering, restart, parent-header prefiltering, isolated child diagnostics, load repair, scan behavior, and stale-index fallback. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. ## Risks -- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, corruption, and fallback behavior. +- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. +- Listing may repair interrupted child logs and persist synthetic closing events even though it creates no Agent. This is the existing `SessionPersistence.load()` contract, not a hidden catalog write. - The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- The no-argument tool returns every direct continuable child and diagnostic. Stable ordering makes the result deterministic but does not bound model-context growth; pagination or deletion remains a later product decision. - Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 5011fc0553..e80e35edde 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -16,29 +16,31 @@ Status: proposed - 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; - 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; -- 排除一次性、损坏、不受支持、缺失或并非直接 child 的会话; -- 叠加进程内 Task 关联,但不将该关联视为持久化状态。 +- 将这些持久化候选与 parent 的进程内 Task 关联合并,包括尚未实际落盘的活跃 child; +- 排除一次性 child 且不产生 diagnostic;如果候选在枚举后变得不可用,或其描述符损坏或版本不受支持,则排除该候选并产生对应 child 的 diagnostic; +- 仅当非活跃 child 的描述符有效,且其提供方当前已注册并实现 `resume?()` 时,才将它对外标记为 `resumable`; +- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 ### 枚举决策 -第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,并且只对这些直接 child 候选调用 `load()` 来归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告该 id 不可用,持久化列表则不会列出它。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 +第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,再将这些 id 与 parent 拥有的 Task 关联合并。已关联的 child 直接从存活关联中解析,绝不会传给 `SessionPersistence.load()`;只有非活跃的直接 child 候选才会被加载以归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告非活跃实例不可用,但活跃关联仍会在 `list_agents` 中显示为 `running`。该 Task 进入终态后,只有在持久化描述符通过校验时,这个 child 才会继续可被发现。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 -列表查询不添加会话事件或 surface 节点。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 +`SessionPersistence.load()` 可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这项现有的持久化副作用:`listChildren()` 不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 ### `list_agents` 契约 -`SubagentControlService.listChildren(parent)` 只返回具有有效可继续描述符的持久化直接 child,再叠加进程内 Task 关联。面向模型的 `list_agents` 工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,并报告两种操作状态: +`SubagentControlService.listChildren(parent)` 返回持久化候选与活跃 Task 关联并集中的所有直接可继续 child,以及无法加载、校验或恢复非活跃候选时产生的非致命 diagnostic。控制服务分配 child id 时,关联会记录其创建时间;已实际落盘的 child 则使用 `SessionHeader.createdAt`。这些 child 先按该 `createdAt` 升序、再按 child id 升序排序,diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器;它会一并渲染完整的已排序 child 和 diagnostic,并报告两种 child 操作状态: -- `running`:存在由非终态 Task 支撑的激活,包括启动阶段和 Task 终态发布前的结算阶段; -- `resumable`:存在有效的持久化描述符,且没有关联任何激活。 +- `running`:存在由非终态 Task 支撑的激活,包括实际落盘前的启动阶段和 Task 终态发布前的结算阶段; +- `resumable`:没有关联任何激活,存在有效的持久化描述符,且其指定的提供方当前已注册并实现 `resume?()`。 -这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。描述符损坏、版本不受支持、parent 不匹配或 child 缺失时,系统会明确失败,而不会将其静默标记为可恢复。 +这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。非活跃候选使用三种固定的 diagnostic 原因:格式错误的已提交数据或描述符内容使用 `corrupt`,未知描述符版本使用 `unsupported`,候选消失、出现其他逐 child 加载失败、其提供方缺失或未实现 `resume?()` 时使用 `unavailable`。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。如果初始 `SessionPersistence.list()` 操作失败,因为系统无法获得候选集,整次调用都会失败。`parentSession` 指向其他 parent 的 header 会在加载描述符前被过滤,且不产生 diagnostic。 -第一版只读,不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 +第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 ## 已考虑的替代方案 @@ -50,17 +52,28 @@ Status: proposed **持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 +**某个 child 无法加载时让整次列表查询失败。** 这种做法不会让损坏问题被忽略,但一个损坏的 sibling 会让每个健康 child 都不再可见。逐 child diagnostic 在保持每次排除明确可见的同时,也保留了发现能力。 + +**添加不会触发修复的描述符检查 API。** 这能使发现严格保持存储只读,但仅为避免中断尾部修复就扩展持久化 seam,而普通会话加载和最终恢复原本就需要执行该修复。第一版接受 `load()` 的语义,并记录这项副作用。 + +**对面向模型的结果分页或设置上限。** 这可以限制一次工具结果的大小,但会使发现成为有状态操作,而且除非模型继续跟随 cursor,否则可能隐藏更早的 child。第一版不接受参数,并返回经稳定排序的完整集合;拥有大量持久化 child 的部署需要接受相应的上下文成本。 + ## 验收标准 -- 枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的 child。 -- 列表查询不加载 Agent、不追加会话事件,并从经过压缩和未经压缩的日志返回相同的 child。 -- `list_agents` 只返回有效的直接可继续 child,并报告 `running` 或 `resumable`,不直接透传运行时状态。 +- 持久化枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的非活跃 child;最终结果会将这些 child 与 parent 拥有的活跃关联合并。 +- 列表查询不加载 Agent,也不会自行追加目录或描述符事件,但可能对非活跃 child 触发 `SessionPersistence.load()` 的中断尾部修复;已关联的 child 绝不会被加载,且经过压缩和未经压缩的日志会返回相同的 child。 +- `list_agents` 不接受参数,返回所有有效的直接可继续 child 及逐 child diagnostic,并按 `createdAt` 升序、child id 升序排序。 +- 活跃 Task 关联即使尚未实际落盘,也会显示为 `running`;Task 进入终态后,只有在描述符校验通过,且当前注册的提供方实现 `resume?()` 时,child 才会显示为 `resumable`。 +- `list_agents` 不直接透传运行时状态,只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 - 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 -- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本、parent 不匹配的 child 和陈旧的派生索引条目绝不会被标记为可恢复。 -- 无密钥测试覆盖压缩前后的发现、重启、错误 parent 访问、不受支持的描述符、扫描行为和陈旧索引回退。面向模型的工具具有可运行的快照覆盖。 +- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本和陈旧的派生索引条目绝不会被标记为可恢复;非 child header 会在加载前被过滤。 +- 损坏、不受支持、已消失或无法加载的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic;只有初始持久化列表查询失败时,整次调用才会失败。 +- 无密钥测试覆盖压缩前后的发现、活跃的尚未实际落盘 child、从正在运行的关联转换为持久化恢复、提供方缺失、稳定排序、重启、parent header 预过滤、单个 child diagnostic 隔离、加载修复、扫描行为和陈旧索引回退。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 ## 风险 -- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、损坏处理和回退行为。 +- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 +- 列表查询可能修复中断的 child 日志并持久化合成的结束事件,即使它不创建 Agent。这是 `SessionPersistence.load()` 的现有契约,而非隐藏的目录写入。 - 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- 无参数工具会返回每个直接可继续 child 和 diagnostic。稳定排序可使结果确定,但不会限制模型上下文的增长;分页或删除仍是后续的产品决策。 - Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 From e63f22f2f1c63d2118c7f6f6eb59dd1d4e70be77 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 10:27:05 +0800 Subject: [PATCH 29/66] temp commit --- ...subagent-catalog-and-list-agents.i18n.yaml | 6 +- ...urable-subagent-catalog-and-list-agents.md | 78 ++++++++++++------- ...ble-subagent-catalog-and-list-agents.zh.md | 78 ++++++++++++------- 3 files changed, 99 insertions(+), 63 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index fee41ed170..73624514e5 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-durable-subagent-catalog-and-list-agents.md: 47c99ee6171bbb64416eeb497146a8aa11ea6869 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: e80e35eddefc050fa49c26cef88df56520eb58f2 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +2026-07-22-durable-subagent-catalog-and-list-agents.md: b8bca3208541fa8154c3587db4c0520d6a2e3d04 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 6ee737212da52df21fd30c4fe78d680dbeaf8a5f diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 47c99ee617..b8bca32085 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -6,39 +6,49 @@ English | [中文](2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) ## Problem -Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable child-handle and activation design is owned by [continuable background subagents](2026-07-21-continuable-background-subagents.md); this note owns enumeration and its model-facing query. +Continuable background subagents expose a stable child id and persist the reconstruction descriptor in that child's session, so `send_message` can resume a known child without any listing operation. `list_agents` has a different requirement: after parent restart, enumerate only that parent's direct continuable children even when the caller no longer knows their ids. The durable Session and Activation design is owned by [continuable subagents](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md); this note owns enumeration and its model-facing query. -Enumeration must cross-check immutable session lineage, descriptor validity, and process-local activation state without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. +Enumeration must cross-check immutable session lineage, descriptor validity, and the live-preferred session corpus without loading or resuming an Agent merely to display it. It must also define how missing, corrupt, deleted, or unsupported children affect the list and whether repeatedly loading many child logs needs an index. ## Proposal -Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-handle contract. `SubagentControlService.listChildren(parent)` must: +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parent)` must: -- find materialized session headers whose `parentSession` identifies the caller's session; -- load and validate each candidate's `subagent/descriptor` event without activating the child; -- union those durable candidates with the parent's process-local Task associations, including active children that have not materialized yet; +- use `ctx.sessionQuery.traceSession(parent.session.id)` to obtain the caller's direct live-preferred child sessions; +- read and validate each candidate's `subagent/descriptor` event without activating the child; - omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; -- expose an inactive child as resumable only when its descriptor is valid and its provider is currently registered with `resume?()`; +- expose only children whose descriptor carries a durable creation `label`; +- report a live child as `running` and a persisted-only child as `complete`; - return every resulting child in stable `createdAt` ascending, child-id ascending order. -Descriptor format, persistence, by-id lookup, direct-parent authorization, and cold resume remain owned by the activation proposal. Listing consumes those facts but cannot weaken them or invent a second descriptor representation. +Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. This proposal extends the descriptor with a durable `label` and requires listing to diagnose duplicate descriptor events; it cannot weaken the existing facts or invent a second descriptor representation. ### Enumeration decision -The first implementation uses `SessionPersistence.list()` to obtain materialized headers, filters on `SessionHeader.parentSession`, and unions those ids with Task associations owned by the parent. An associated child is resolved from the live association and is never passed to `SessionPersistence.load()`; only inactive direct-child candidates are loaded to fold their descriptors. The activation contract calls a preallocated id without a durable header and descriptor an **unmaterialized child**: by-id control reports an inactive instance as unavailable, but an active association still appears in `list_agents` as `running`. Once that Task becomes terminal, the child remains discoverable only if its durable descriptor validates. A materialized one-shot child lacks the descriptor and is excluded. This path requires no parent-session catalog event or new persistence backend. +The first implementation consumes `ctx.sessionQuery.traceSession(parent.session.id)` and considers only the trace's first-level descendants. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. -This O(number of direct children) load path is the correctness baseline. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unmaterialized child visible. +Corpus construction precedes per-child descriptor inspection. A failure while building the initial trace, including persistence listing failure, a live/persisted header conflict anywhere in the observed corpus, or invalid target lineage, fails the whole `list_agents` call because no trustworthy candidate set exists. Only failures after a successful trace are isolated to one candidate; "corrupt child" in that per-child contract therefore means corrupt loaded event surface or descriptor data, not a corpus-level header conflict. -`SessionPersistence.load()` may durably repair an interrupted child log by appending synthetic closing events. The first version accepts this existing persistence side effect: `listChildren()` creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. It reads the model-hidden descriptor retained in the child log by the activation contract, so compacted and uncompacted children must enumerate identically. +Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. Each candidate must therefore contain exactly one valid `subagent/descriptor` event. The activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another activation. The event distinguishes a continuable background subagent from an ordinary fork or one-shot child; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is omitted without a diagnostic. + +The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. + +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren()` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. + +This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C the number of persisted sessions scanned by each persistence listing, and L_i the size of candidate i's full log. One corpus trace is followed by two exact reads per candidate. `listChildren()` uses `sessionQuery.listEvents(childId)` to locate the sole descriptor event and `sessionQuery.readEvent({ sessionId: childId, seq })` to read it, and each operation independently loads the logical session. In the persisted-only worst case, every exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a live child instead takes two detached in-memory snapshots of its full log. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts the repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. + +If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unpublished child visible. ### `list_agents` contract -`SubagentControlService.listChildren(parent)` returns all direct continuable children in the union of durable candidates and active Task associations, plus non-fatal diagnostics for inactive candidates it could not load, validate, or resume. An association records its creation time when the control service allocates the child id; a materialized child uses `SessionHeader.createdAt`. Children are sorted by that `createdAt` ascending, then child id ascending. Diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`; it renders the complete sorted children and diagnostics together, and reports two operational child states: +`SubagentService.listChildren(parent)` returns every direct continuable child found in the session trace plus non-fatal diagnostics for candidates it could not read or validate. Each child carries its session id, descriptor `label`, and one of two snapshot states: -- `running`: a non-terminal Task-backed activation exists, including startup before materialization and settlement before Task terminal publication; -- `resumable`: no activation is associated, a valid durable descriptor exists, and the named provider is currently registered with `resume?()`. +- `running`: the logical session record is live in `ctx.sessions`; +- `complete`: the logical session record exists only in persistence and may be resumed by `send_message`. -These values are not `AgentStatus`. A plain Agent registry entry without a Task association is an ownership conflict, not a third list state. Inactive candidates use three diagnostic reasons: `corrupt` for malformed committed data or descriptor content, `unsupported` for an unknown descriptor version, and `unavailable` when the candidate disappears, another child-specific load fails, or its provider is absent or lacks `resume?()`. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Failure of the initial `SessionPersistence.list()` operation fails the whole call because no candidate set exists. Headers whose `parentSession` names another parent are filtered before descriptor loading and produce no diagnostic. +These values are neither `AgentStatus` nor the manager's internal Activation state. Children are sorted by `SessionHeader.createdAt` ascending, then child id ascending; diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` that renders the complete sorted children and diagnostics together. + +Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; v1 neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a one-shot exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic. The first version has no child deletion operation. If later product behavior deletes child sessions, persistence listing naturally drops a deleted child; any future derived index must remove or tombstone the same entry so `list_agents` cannot retain stale state. @@ -46,9 +56,13 @@ The first version has no child deletion operation. If later product behavior del **Fold listing into the activation RFC.** Descriptor-by-id persistence and cold resume do not require parent-to-child enumeration. Keeping the query separate lets `send_message` land without taking on listing states, scanning performance, or deletion behavior. -**List every persisted session whose header names the parent.** `parentSession` proves lineage but does not prove that the child is continuable. Listing must also load and validate the descriptor. +**Rebuild lineage directly from `SessionPersistence.list()`.** This duplicates session query's live-preferred corpus merge, immutable-header consistency checks, direct-child tracing, and deterministic ordering. Listing uses the existing trusted query service and adds only subagent-specific descriptor validation and rendering. -**Use the live Agent registry as the catalog.** Runs are deliberately disposed after every Task, and registry state disappears on restart. It cannot support durable discovery. +**List every traced child session.** `parentSession` proves lineage but does not prove that the child is a continuable subagent: ordinary session forks and one-shot subagents share that header field. Listing must also read and validate the descriptor. + +**Use the live Agent registry as the catalog.** Activations are deliberately disposed after settlement, and registry state disappears on restart. It cannot support durable discovery. + +**Use the process-local Activation map as a second catalog.** This exposes manager residency but couples a session-discovery query to materialization and settlement, introduces another ordering clock, and makes the same child change candidate source during its lifetime. The first version lists published logical sessions only and treats `SessionRecord.live` as its snapshot status. **Persist a parent-session catalog event.** Direct-child headers already provide the durable enumeration seed, and the child descriptor is the reconstruction authority. A second parent log duplicates state and creates cross-session ordering and stale-entry behavior without helping by-id resume. @@ -60,20 +74,24 @@ The first version has no child deletion operation. If later product behavior del ## Acceptance criteria -- Durable enumeration uses materialized session headers as candidates, validates `parentSession`, and includes only inactive children whose persisted descriptor satisfies the durable child-handle contract; the final result unions those children with parent-owned active associations. -- Listing loads no Agent and appends no catalog or descriptor event itself, but may trigger `SessionPersistence.load()` interrupted-tail repair for inactive children; an already-associated child is never loaded, and compacted and uncompacted logs return the same children. -- `list_agents` takes no arguments and returns all valid direct continuable children plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. -- Active Task associations appear as `running` even before durable materialization; after Task terminal, the child appears as `resumable` only when its descriptor validates and its currently registered provider implements `resume?()`. -- `list_agents` reports no pass-through runtime status, uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons, and never exposes descriptor contents in a diagnostic. -- Parent resume does not activate children; listing reads durable state and overlays only already-associated process-local Tasks. -- A preallocated-but-unmaterialized child id, one-shot child, corrupt descriptor, unsupported descriptor version, and stale derived-index entry are never advertised as resumable; non-child headers are filtered before load. -- A corrupt, unsupported, disappeared, or unloadable candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic, while failure of the initial persistence listing fails the whole call. -- Keyless tests cover fresh and compacted discovery, active unmaterialized children, transition from running association to durable resume, provider absence, stable ordering, restart, parent-header prefiltering, isolated child diagnostics, load repair, scan behavior, and stale-index fallback. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. +- Enumeration uses `ctx.sessionQuery.traceSession(parent.session.id)`, considers only direct descendants, and does not duplicate corpus merging, lineage reconstruction, or sibling ordering. +- Listing loads no Agent, materializes no Activation, and appends no catalog or descriptor event itself. After the initial trace it performs two independent exact session-query reads per candidate; persisted reads may trigger interrupted-tail repair, and compacted and uncompacted logs return the same children. +- A valid descriptor includes the delegation's durable `label`; ordinary session forks and one-shot children lack that descriptor and are omitted without a diagnostic. Provider registration does not affect discovery or provider-independent cold resume. +- Initial creation writes exactly one descriptor event, cold resume writes none, and a candidate with more than one descriptor event is diagnosed as `corrupt`. +- `list_agents` takes no arguments and returns every valid direct continuable child with its id, label, and `running` or `complete` snapshot state, plus per-child diagnostics, sorted by `createdAt` ascending and child id ascending. +- A live logical session is `running`; a persisted-only logical session is `complete` and remains eligible for a later `send_message`. The result does not consult the process-local Activation map. +- Parent resume does not activate children. A child is absent until its session is published, and listing may race publication, disposal, or later delivery without weakening `send_message`'s execution-time checks. +- `list_agents` uses only `corrupt`, `unsupported`, or `unavailable` diagnostic reasons and never exposes descriptor contents in a diagnostic. +- After a successful initial trace, a corrupt, unsupported, disappeared, or unreadable descriptor candidate cannot hide healthy siblings: it is omitted with an id-and-reason diagnostic. Corpus-level persistence, header-consistency, or lineage failure during that initial trace fails the whole call. +- Per-child session-query failures map deterministically: invalid surfaces and exact-load source conflicts are `corrupt`; missing sessions or events and persistence failures are `unavailable`; unknown descriptor versions are `unsupported`; and missing descriptors are omitted as one-shot children. +- The list tool requires `sessionQuery` at plugin load; a direct `listChildren()` call without it fails before enumeration with `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`, while by-id `send_message` remains usable without that service. +- Keyless tests cover fresh and compacted discovery, ordinary fork and one-shot exclusion, live-to-complete transition, unmanaged-live-session snapshots, provider-independent discovery, durable `label` values, stable ordering, restart, direct-child tracing, duplicate descriptor rejection, isolated child diagnostics, phase-dependent persistence failure, load repair, snapshot races, and scan behavior. The model-facing complete-list-plus-diagnostics result has runnable snapshot coverage. ## Risks -- Listing performs one header scan and may load every direct-child log; a later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. -- Listing may repair interrupted child logs and persist synthetic closing events even though it creates no Agent. This is the existing `SessionPersistence.load()` contract, not a hidden catalog write. -- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by active Tasks. +- Session tracing observes the complete logical corpus, then descriptor validation reads each direct-child log twice. In the persisted-only worst case, work is O(D × C + Σ L_i), not merely O(D), because each exact read rescans persistence and loads and clones the full candidate log. A later derived index must preserve the same authorization, per-child diagnostic, and fallback behavior. +- Corpus construction is an all-or-nothing trust boundary: one live/persisted header conflict can fail the initial trace and hide otherwise healthy siblings. Per-child isolation begins only after that trace succeeds. +- Session-query reads may repair interrupted child logs and persist synthetic closing events even though listing creates no Agent. This is the existing persistence-load contract, not a hidden catalog write. +- The first version has no deletion operation, so persisted children remain listed for as long as their sessions remain in persistence even though live Agent resources remain bounded by resident Activations. - The no-argument tool returns every direct continuable child and diagnostic. Stable ordering makes the result deterministic but does not bound model-context growth; pagination or deletion remains a later product decision. -- Task associations exist only in one runtime. Another process can report a durable child as `resumable` while work for that child is active elsewhere unless the deployment adds a shared lease. +- `running` and `complete` are process-local corpus snapshots, not delivery promises. Another process may activate a persisted child while this process reports it as `complete`; cross-process accuracy requires a shared lease. diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index e80e35edde..6ee737212d 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -6,39 +6,49 @@ Status: proposed ## 问题 -可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续的后台 subagent](2026-07-21-continuable-background-subagents.md)负责持久化 child handle 与激活设计;本记录负责枚举及其面向模型的查询。 +可继续的后台 subagent 会公开稳定的 child id,并将重建描述符持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。`list_agents` 的要求不同:parent 重启后,即使调用方不再知道各 child id,也要只枚举该 parent 的直接可继续 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)负责持久化 Session 与 Activation 设计;本记录负责枚举及其面向模型的查询。 -枚举必须交叉核对不可变的会话谱系、描述符有效性与进程内激活状态,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 +枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。它还必须定义缺失、损坏、已删除或不受支持的 child 如何影响列表,以及反复加载大量 child 日志是否需要索引。 ## 提案 -将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child handle 契约、单独评审的功能。`SubagentControlService.listChildren(parent)` 必须: +将 parent 到 child 的枚举与 `list_agents` 作为一个基于持久化 child Session 契约、单独评审的功能。`SubagentService.listChildren(parent)` 必须: -- 查找 `parentSession` 将调用方会话标识为 parent 的已实际落盘会话 header; -- 加载并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; -- 将这些持久化候选与 parent 的进程内 Task 关联合并,包括尚未实际落盘的活跃 child; +- 使用 `ctx.sessionQuery.traceSession(parent.session.id)` 获取调用方直接且实时优先的 child 会话; +- 读取并校验每个候选会话的 `subagent/descriptor` 事件,但不激活 child; - 排除一次性 child 且不产生 diagnostic;如果候选在枚举后变得不可用,或其描述符损坏或版本不受支持,则排除该候选并产生对应 child 的 diagnostic; -- 仅当非活跃 child 的描述符有效,且其提供方当前已注册并实现 `resume?()` 时,才将它对外标记为 `resumable`; +- 只公开描述符带有持久化创建 `label` 的 child; +- 将存活 child 报告为 `running`,只存在于持久化存储中的 child 报告为 `complete`; - 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 -描述符格式、持久化、按 id 查找、直接 parent 鉴权与从持久化存储恢复仍由激活提案负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 +描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。本提案会为描述符增加持久化 `label`,并要求列表查询诊断重复的描述符事件;它不能削弱现有事实,也不能发明第二种描述符表示。 ### 枚举决策 -第一版使用 `SessionPersistence.list()` 获取已实际落盘的 header,按 `SessionHeader.parentSession` 过滤,再将这些 id 与 parent 拥有的 Task 关联合并。已关联的 child 直接从存活关联中解析,绝不会传给 `SessionPersistence.load()`;只有非活跃的直接 child 候选才会被加载以归并其描述符。激活契约将已预分配 id、却没有持久化 header 和描述符的 child 称为 **unmaterialized child**:按 id 的控制操作会报告非活跃实例不可用,但活跃关联仍会在 `list_agents` 中显示为 `running`。该 Task 进入终态后,只有在持久化描述符通过校验时,这个 child 才会继续可被发现。已实际落盘的一次性 child 没有描述符,因此会被排除。这条路径无需 parent 会话目录事件或新的持久化后端。 +第一版消费 `ctx.sessionQuery.traceSession(parent.session.id)`,并且只考虑追踪结果的第一层后代。会话查询已经使用实时优先规则合并 `ctx.sessions` 与 `ctx.sessionPersistence`,保持不可变 header 一致性,根据 `SessionHeader.parentSession` 推导直接 child 谱系,并按 `createdAt` 升序、child id 升序排列 sibling。`listChildren()` 不会重复实现这套语料逻辑,也不会检查继续执行管理器的进程内 Activation map。 -这条 O(直接 child 数量)加载路径是正确性基线。如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未实际落盘的 child 变得可见。 +语料构建先于逐 child 描述符检查。构建初始追踪时如果发生持久化列表查询失败、所观测语料中任意位置的存活/持久化 header 冲突或目标谱系无效,整个 `list_agents` 调用都会失败,因为此时不存在可信的候选集。只有初始追踪成功后的失败才会被隔离到单个候选;因此,这项逐 child 契约中的“损坏 child”是指已加载的事件 surface 或描述符数据损坏,而不是语料级 header 冲突。 -`SessionPersistence.load()` 可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这项现有的持久化副作用:`listChildren()` 不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。它读取激活契约保留在 child 日志中、对模型隐藏的描述符,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 +会话谱系涵盖的范围比 subagent 身份更广:普通 `ctx.sessions.fork()` 和一次性 subagent 也会创建直接 child。因此,每个候选都必须恰好包含一个有效的 `subagent/descriptor` 事件。激活契约只在初始创建期间写入该事件,从持久化存储恢复时不会追加其他描述符;第二个事件属于损坏,而不是另一次激活的证据。该事件用于区分可继续的后台 subagent 与普通 fork 或一次性 child;其简短创建 `label` 来自委派的 `description`,其余继续执行字段仍是不依赖提供方的冷恢复所使用的重建输入。缺少该事件的候选会被排除,且不产生 diagnostic。 + +已发布的逻辑记录同时也是状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `complete`。`complete` 表示当前没有存活的 Activation,既不表示执行成功,也不表示 child 已永久关闭;`send_message` 仍可物化另一次 Activation。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活 Agent 仍会显示为 `running`,但 `send_message` 会拒绝,而不会接管它。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。 + +subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren()` 方法在调用时解析这个可选服务;如果服务缺失,该方法会在执行任何工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在不加载会话查询的情况下使用 `send_message`;列表工具会在插件加载时捕获配置错误,而其他直接服务消费方会收到同一项明确的调用时契约。 + +这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选会执行两次精确读取。`listChildren()` 使用 `sessionQuery.listEvents(childId)` 定位唯一的描述符事件,并使用 `sessionQuery.readEvent({ sessionId: childId, seq })` 读取该事件;每项操作都会独立加载逻辑会话。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 `persistence.list()`、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);存活 child 则会对其完整日志取得两份分离的内存快照。持久化路径可能通过追加合成的结束事件,持久修复中断的 child 日志。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会自行追加目录或描述符事件,但它并非严格的存储只读操作。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。 + +如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未发布的 child 变得可见。 ### `list_agents` 契约 -`SubagentControlService.listChildren(parent)` 返回持久化候选与活跃 Task 关联并集中的所有直接可继续 child,以及无法加载、校验或恢复非活跃候选时产生的非致命 diagnostic。控制服务分配 child id 时,关联会记录其创建时间;已实际落盘的 child 则使用 `SessionHeader.createdAt`。这些 child 先按该 `createdAt` 升序、再按 child id 升序排序,diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器;它会一并渲染完整的已排序 child 和 diagnostic,并报告两种 child 操作状态: +`SubagentService.listChildren(parent)` 返回会话追踪中找到的每个直接可继续 child,以及无法读取或校验候选时产生的非致命 diagnostic。每个 child 都携带自己的 session id、描述符 `label`,以及两种快照状态之一: -- `running`:存在由非终态 Task 支撑的激活,包括实际落盘前的启动阶段和 Task 终态发布前的结算阶段; -- `resumable`:没有关联任何激活,存在有效的持久化描述符,且其指定的提供方当前已注册并实现 `resume?()`。 +- `running`:逻辑会话记录在 `ctx.sessions` 中存活; +- `complete`:逻辑会话记录只存在于持久化存储中,并且可以由 `send_message` 恢复。 -这些值并非 `AgentStatus`。普通 Agent 注册表中没有 Task 关联的条目属于所有权冲突,而不是第三种列表状态。非活跃候选使用三种固定的 diagnostic 原因:格式错误的已提交数据或描述符内容使用 `corrupt`,未知描述符版本使用 `unsupported`,候选消失、出现其他逐 child 加载失败、其提供方缺失或未实现 `resume?()` 时使用 `unavailable`。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。如果初始 `SessionPersistence.list()` 操作失败,因为系统无法获得候选集,整次调用都会失败。`parentSession` 指向其他 parent 的 header 会在加载描述符前被过滤,且不产生 diagnostic。 +这些值既不是 `AgentStatus`,也不是管理器内部的 Activation 状态。child 按 `SessionHeader.createdAt` 升序、再按 child id 升序排序;diagnostic 使用其候选的同一排序键。面向模型的 `list_agents` 工具不接受参数,它是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器,会一并渲染完整的已排序 child 和 diagnostic。 + +diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则视为一次性 child,直接排除且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。 第一版不提供 child 删除操作。如果后续产品行为会删除 child 会话,持久化列表会自然移除已删除的 child;任何未来的派生索引都必须移除或 tombstone 同一条目,避免 `list_agents` 保留陈旧状态。 @@ -46,9 +56,13 @@ Status: proposed **将列表查询并入激活 RFC。** 按 id 持久化描述符和从持久化存储恢复无需 parent 到 child 的枚举。保持查询独立,可让 `send_message` 落地时不必同时承担列表状态、扫描性能或删除行为。 -**枚举 header 中以该 parent 为 parent 的每个持久化会话。** `parentSession` 能证明谱系,却不能证明 child 可继续。列表查询还必须加载并校验描述符。 +**直接通过 `SessionPersistence.list()` 重建谱系。** 这种做法会重复实现会话查询中的实时优先语料合并、不可变 header 一致性检查、直接 child 追踪和确定性排序。列表查询应使用现有可信查询服务,只增加 subagent 特有的描述符校验与渲染。 -**使用存活的 Agent 注册表作为目录。** 系统会在每个 Task 结束后有意 dispose 对应 run,而且注册表状态会在重启时消失,因此无法支持持久化发现。 +**列出每个已追踪的 child 会话。** `parentSession` 能证明谱系,却不能证明 child 是可继续的 subagent:普通会话 fork 和一次性 subagent 也使用这个 header 字段。列表查询还必须读取并校验描述符。 + +**使用存活的 Agent 注册表作为目录。** 系统会在 Activation 结算后有意 dispose 它,而且注册表状态会在重启时消失,因此无法支持持久化发现。 + +**使用进程内 Activation map 作为第二个目录。** 这种做法能公开管理器驻留状态,却会让会话发现查询与物化及结算耦合,引入另一套排序时钟,并让同一个 child 在其生命周期内改变候选来源。第一版只列出已经发布的逻辑会话,并将 `SessionRecord.live` 视为其快照状态。 **持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。 @@ -60,20 +74,24 @@ Status: proposed ## 验收标准 -- 持久化枚举使用已实际落盘的会话 header 作为候选,校验 `parentSession`,并且只包含持久化描述符满足持久化 child handle 契约的非活跃 child;最终结果会将这些 child 与 parent 拥有的活跃关联合并。 -- 列表查询不加载 Agent,也不会自行追加目录或描述符事件,但可能对非活跃 child 触发 `SessionPersistence.load()` 的中断尾部修复;已关联的 child 绝不会被加载,且经过压缩和未经压缩的日志会返回相同的 child。 -- `list_agents` 不接受参数,返回所有有效的直接可继续 child 及逐 child diagnostic,并按 `createdAt` 升序、child id 升序排序。 -- 活跃 Task 关联即使尚未实际落盘,也会显示为 `running`;Task 进入终态后,只有在描述符校验通过,且当前注册的提供方实现 `resume?()` 时,child 才会显示为 `resumable`。 -- `list_agents` 不直接透传运行时状态,只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 -- 恢复 parent 不会激活 child;列表查询读取持久化状态,并且只叠加已经关联的进程内 Task。 -- 已预分配但尚未实际落盘的 child id、一次性 child、损坏描述符、不受支持的描述符版本和陈旧的派生索引条目绝不会被标记为可恢复;非 child header 会在加载前被过滤。 -- 损坏、不受支持、已消失或无法加载的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic;只有初始持久化列表查询失败时,整次调用才会失败。 -- 无密钥测试覆盖压缩前后的发现、活跃的尚未实际落盘 child、从正在运行的关联转换为持久化恢复、提供方缺失、稳定排序、重启、parent header 预过滤、单个 child diagnostic 隔离、加载修复、扫描行为和陈旧索引回退。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 +- 枚举使用 `ctx.sessionQuery.traceSession(parent.session.id)`,只考虑直接后代,并且不重复实现语料合并、谱系重建或 sibling 排序。 +- 列表查询不会加载 Agent、物化 Activation,也不会自行追加目录或描述符事件。初始追踪完成后,它会对每个候选执行两次相互独立的会话查询精确读取;持久化读取可能触发中断尾部修复,且经过压缩和未经压缩的日志会返回相同的 child。 +- 有效描述符包含委派的持久化 `label`;普通会话 fork 和一次性 child 缺少该描述符,因此会被排除且不产生 diagnostic。提供方注册状态不影响发现,也不影响不依赖提供方的冷恢复。 +- 初始创建恰好写入一个描述符事件,从持久化存储恢复时不写入任何描述符;如果候选包含多个描述符事件,则将其诊断为 `corrupt`。 +- `list_agents` 不接受参数,返回每个有效的直接可继续 child 及其 id、label 和 `running` 或 `complete` 快照状态,并返回逐 child diagnostic;结果按 `createdAt` 升序、child id 升序排序。 +- 存活的逻辑会话为 `running`;只存在于持久化存储中的逻辑会话为 `complete`,并且仍可在之后通过 `send_message` 恢复。结果不查询进程内 Activation map。 +- 恢复 parent 不会激活 child。child 会话发布前不会出现,列表查询可能与发布、dispose 或后续消息送达发生竞态,但不会削弱 `send_message` 在执行时进行的检查。 +- `list_agents` 只使用 `corrupt`、`unsupported` 或 `unavailable` 作为 diagnostic 原因,且绝不在 diagnostic 中暴露描述符内容。 +- 初始追踪成功后,描述符损坏、不受支持、已消失或无法读取的候选不能隐藏健康的 sibling:系统会排除该候选,并生成一条含 id 和原因的 diagnostic。初始追踪期间发生的语料级持久化、header 一致性或谱系失败会让整次调用失败。 +- 逐 child 会话查询失败采用固定映射:无效 surface 和精确加载时的来源冲突映射为 `corrupt`;会话或事件缺失以及持久化失败映射为 `unavailable`;未知描述符版本映射为 `unsupported`;缺少描述符则作为一次性 child 排除。 +- 列表工具在插件加载时要求 `sessionQuery`;直接调用 `listChildren()` 时如果缺少该服务,则会在枚举前以 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 失败,而按 id 的 `send_message` 在没有该服务时仍可使用。 +- 无密钥测试覆盖压缩前后的发现、排除普通 fork 和一次性 child、从存活到 complete 的转换、未受管理的存活会话快照、不依赖提供方的发现、持久化 `label` 值、稳定排序、重启、直接 child 追踪、重复描述符拒绝、单个 child diagnostic 隔离、依阶段而异的持久化失败、加载修复、快照竞态和扫描行为。面向模型的完整列表加 diagnostic 结果具有可运行的快照覆盖。 ## 风险 -- 列表查询会扫描一次 header,并且可能加载每个直接 child 的日志;后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 -- 列表查询可能修复中断的 child 日志并持久化合成的结束事件,即使它不创建 Agent。这是 `SessionPersistence.load()` 的现有契约,而非隐藏的目录写入。 -- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由活跃 Task 数量限制。 +- 会话追踪会观察完整的逻辑语料,随后描述符校验会读取每个直接 child 的日志两次。对于只存在于持久化存储中的最坏情况,工作量为 O(D × C + Σ L_i),而不只是 O(D),因为每次精确读取都会重新扫描持久化存储,并加载和克隆候选的完整日志。后续的派生索引必须保持相同的鉴权、逐 child diagnostic 和回退行为。 +- 语料构建是一个全有或全无的信任边界:一处存活/持久化 header 冲突就可能导致初始追踪失败,并隐藏原本健康的 sibling。只有初始追踪成功后,逐 child 隔离才会生效。 +- 会话查询读取可能修复中断的 child 日志并持久化合成的结束事件,即使列表查询不创建 Agent。这是现有的持久化加载契约,而非隐藏的目录写入。 +- 第一版没有删除操作,因此只要 child 会话仍保留在持久化存储中,它们就会继续出现在列表里,但存活 Agent 资源仍由驻留 Activation 数量限制。 - 无参数工具会返回每个直接可继续 child 和 diagnostic。稳定排序可使结果确定,但不会限制模型上下文的增长;分页或删除仍是后续的产品决策。 -- Task 关联仅存在于一个运行时中。除非部署添加共享租约,否则当另一个进程正在处理某个持久化 child 时,当前进程仍可能将其报告为 `resumable`。 +- `running` 和 `complete` 是进程内语料快照,而非消息送达承诺。另一个进程可能在当前进程将某个持久化 child 报告为 `complete` 时激活它;跨进程准确性需要共享租约。 From c7acc8fc6cc2bdb27d9b6927b698d3362021a822 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 16:09:20 +0800 Subject: [PATCH 30/66] docs: finalize list_agents RFC contract --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 59 +++++++++++-------- ...ble-subagent-catalog-and-list-agents.zh.md | 59 +++++++++++-------- 3 files changed, 72 insertions(+), 50 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 73624514e5..7b16ca1911 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -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/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: b8bca3208541fa8154c3587db4c0520d6a2e3d04 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 6ee737212da52df21fd30c4fe78d680dbeaf8a5f +2026-07-22-durable-subagent-catalog-and-list-agents.md: 23d5b3924a20ae84132048b26e12779a98a6f2bb +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: cac14271515d031a39bf6690f199c97be7bb9fa9 diff --git a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index b8bca32085..23d5b3924a 100644 --- a/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/proposed/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -12,12 +12,12 @@ Enumeration must cross-check immutable session lineage, descriptor validity, and ## Proposal -Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parent)` must: +Treat parent-to-child enumeration and `list_agents` as one separately reviewed feature built on the durable child-session contract. `SubagentService.listChildren(parentSessionId: SessionId)` must: -- use `ctx.sessionQuery.traceSession(parent.session.id)` to obtain the caller's direct live-preferred child sessions; +- use `ctx.sessionQuery.traceSession(parentSessionId)` to obtain the parent's direct live-preferred child sessions; - read and validate each candidate's `subagent/descriptor` event without activating the child; -- omit one-shot children without a diagnostic, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; -- expose only children whose descriptor carries a durable creation `label`; +- silently omit candidates without a descriptor, and omit a candidate that becomes unavailable or has a corrupt or unsupported descriptor with a per-child diagnostic; +- expose every child whose supported valid descriptor carries a durable creation `label`, regardless of whether its provider is currently registered; - report a live child as `running` and a persisted-only child as `complete`; - return every resulting child in stable `createdAt` ascending, child-id ascending order. @@ -25,30 +25,34 @@ Descriptor persistence, by-id lookup, direct-parent authorization, and provider- ### Enumeration decision -The first implementation consumes `ctx.sessionQuery.traceSession(parent.session.id)` and considers only the trace's first-level descendants. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. +The first implementation consumes `ctx.sessionQuery.traceSession(parentSessionId)` and considers only the trace's first-level descendants. The target may be live or persisted; tracing the logical corpus does not load or resume an Agent. Session query already merges `ctx.sessions` with `ctx.sessionPersistence` using live precedence, preserves immutable-header consistency, derives direct-child lineage from `SessionHeader.parentSession`, and sorts siblings by `createdAt` ascending and child id ascending. `listChildren()` does not reproduce that corpus logic or inspect the continuation manager's process-local Activation map. Corpus construction precedes per-child descriptor inspection. A failure while building the initial trace, including persistence listing failure, a live/persisted header conflict anywhere in the observed corpus, or invalid target lineage, fails the whole `list_agents` call because no trustworthy candidate set exists. Only failures after a successful trace are isolated to one candidate; "corrupt child" in that per-child contract therefore means corrupt loaded event surface or descriptor data, not a corpus-level header conflict. -Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. Each candidate must therefore contain exactly one valid `subagent/descriptor` event. The activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another activation. The event distinguishes a continuable background subagent from an ordinary fork or one-shot child; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is omitted without a diagnostic. +Session lineage is broader than subagent identity: an ordinary `ctx.sessions.fork()` and a one-shot subagent also create direct children. The session header gains no `kind` discriminator; each candidate must instead contain exactly one valid `subagent/descriptor` event. The Activation contract writes it only during initial creation and cold resume appends no further descriptor; a second event is corruption rather than evidence of another Activation. The event is the sole evidence that a traced child is a continuable background subagent; its short creation `label` comes from the delegation's `description`, while its continuation fields remain the reconstruction input for provider-independent cold resume. A candidate without the event is an ordinary fork, one-shot child, or another non-continuable session and is omitted without a diagnostic. -The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. +The published logical record is also the status source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `complete`. This status comes directly from the trace and causes no additional child-log load. `complete` means that no Activation is live; it encodes neither successful completion nor a permanently closed child, and `send_message` may materialize another Activation. Conversely, `running` says only that the session is live: a live Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects rather than adopting it. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or status source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. -The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren()` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service when called and throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before doing any work when it is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without loading session query; the list tool catches misconfiguration at plugin load, while another direct service consumer receives the same explicit call-time contract. -This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C the number of persisted sessions scanned by each persistence listing, and L_i the size of candidate i's full log. One corpus trace is followed by two exact reads per candidate. `listChildren()` uses `sessionQuery.listEvents(childId)` to locate the sole descriptor event and `sessionQuery.readEvent({ sessionId: childId, seq })` to read it, and each operation independently loads the logical session. In the persisted-only worst case, every exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a live child instead takes two detached in-memory snapshots of its full log. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts the repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. +This descriptor-read path is the correctness baseline, not a claim that work is linear only in the number of direct children. Let D be the number of direct-child candidates, C be the number of persisted sessions scanned by each persistence listing, and L_i be the size of candidate i's full log. One corpus trace is followed by `sessionQuery.listEvents(childId)` for every candidate. A candidate with no descriptor is omitted, and one with multiple descriptors is diagnosed without another read; only a candidate with exactly one descriptor is loaded again through `sessionQuery.readEvent({ sessionId: childId, seq })`. The read must return the same immutable session header observed by the trace, including the direct-parent relationship, and its target must still be the located descriptor event; a mismatch is per-child corruption. In the persisted-only worst case, each exact read repeats `persistence.list()`, loads the full child log, and clones its events, for O(D × C + Σ L_i) work up to constant factors; a candidate with exactly one descriptor pays those costs twice, while other candidates pay them once. A live candidate similarly takes one detached in-memory snapshot of its full log, or two when its descriptor is read. The persisted path may durably repair an interrupted child log by appending synthetic closing events. The first version accepts these repeated reads as the no-index correctness baseline, but deployments must treat total corpus and child-log size—not only direct-child count—as the capacity constraint. Listing creates no Agent and appends no catalog or descriptor event itself, but it is not a storage-read-only operation. The model-hidden descriptor remains outside the conversation surface and survives compaction, so compacted and uncompacted children must enumerate identically. If measured scale later requires an index, that index is derived state: session headers and child descriptors remain authoritative, and rebuilding or corruption fallback must reproduce the same results. An index cannot become a second authorization source or make an unpublished child visible. ### `list_agents` contract -`SubagentService.listChildren(parent)` returns every direct continuable child found in the session trace plus non-fatal diagnostics for candidates it could not read or validate. Each child carries its session id, descriptor `label`, and one of two snapshot states: +`SubagentService.listChildren(parentSessionId: SessionId)` returns `Promise`, one array preserving the trace's candidate order rather than separate child and diagnostic arrays. `SubagentListEntry` is a closed union discriminated by its readonly `kind`: -- `running`: the logical session record is live in `ctx.sessions`; -- `complete`: the logical session record exists only in persistence and may be resumed by `send_message`. +- `kind: 'child'` carries readonly `id: SessionId`, durable `label: string`, and `status: 'running' | 'complete'`; +- `kind: 'diagnostic'` carries readonly `id: SessionId` and `reason: 'corrupt' | 'unsupported' | 'unavailable'`. -These values are neither `AgentStatus` nor the manager's internal Activation state. Children are sorted by `SessionHeader.createdAt` ascending, then child id ascending; diagnostics follow their candidate's same key. The model-facing `list_agents` tool takes no arguments and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control` that renders the complete sorted children and diagnostics together. +A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. Child status `running` means that the logical record is live in `ctx.sessions`; `complete` means that it exists only in persistence. These values are neither `AgentStatus` nor the manager's internal Activation state, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this proposal. -Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; v1 neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a one-shot exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic. +The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It renders entries in array order as ` [] —