feat(subagent): continuable background subagents

Implement the continuable background subagents RFC: a durable child
session with a series of Task-backed activations, each disposing its
run before the Task settles.

- dsh-subagent: rename SubagentRun.sendMessage to strict steer, drop
  run-level resume, add SubagentProvider.resume dispatch via
  SubagentService.resume, the continuation start field, and the
  versioned model-hidden subagent/descriptor session event.
- dsh-subagent-inprocess/-spawn/-fork: publish the control-allocated
  child id, append the descriptor inside the initial turn, implement
  cold resume from the child's own transcript under the live parent
  scope, and strict running-only steer.
- dsh-subagent-control (new): SubagentControlService owning stable
  child ids, descriptor snapshot/fold/authorization, Task-backed
  activation with settle-then-dispose ordering, the process-local
  active-run association, and steer-or-resume sendMessage routing.
- dsh-tool-subagent: background route branches on the provider's
  resume capability (continuable via the control service; one-shot
  task for ACP), returning both child and task ids.
- dsh-tool-subagent-control (new): the globally named send_message
  tool rendering steered/started routes.

Keyless coverage spans Task ownership and disposal ordering, running
delivery, cold follow-up, descriptor rejection and rollback, known-id
reconstruction, kill during lookup, admission races, and a new
subagent-continuable ACP snapshot scenario.
This commit is contained in:
Dudu-0223
2026-07-23 17:07:38 +08:00
committed by imccyu
parent 76ff841279
commit 99a778d63f
83 changed files with 3167 additions and 627 deletions

View File

@@ -4,13 +4,13 @@ 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 consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.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 global `send_message`). Continuable-child orchestration lives on `ctx.subagentControl` in [dsh-subagent-control](../../packages/subagent/subagent-control). The proposals and rationale: [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).
Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.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 (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism.
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: strict live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider).
```ts type-equiv
/**
@@ -18,9 +18,10 @@ A provider advertises its **start-time** features on a static descriptor the ser
* {@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 such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
* `depthLimit` to `maxDepth`; the other names match.
* capabilities are optional methods whose presence is the capability — strict 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.
*/
interface SubagentCapabilities {
readonly outputSchema: boolean
@@ -88,11 +89,71 @@ interface SubagentStartRequest {
* persona (strict `{{…}}` interpolation against the registered variables).
*/
readonly persona?: string
/**
* Continuable-child intent, resolved by the control service before start.
* The provider MUST publish exactly `sessionId` as the child identity
* instead of allocating one internally, and MUST append the snapshotted
* `descriptor` as the child's turn-enclosed `subagent/descriptor` event
* before its first request. Requires {@link SubagentProvider.resume} (the
* continuation capability); the service rejects the request otherwise.
*/
readonly continuation?: SubagentContinuation
}
```
`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.
## Continuable children: `SubagentContinuation` and `SubagentResumeRequest`
A **continuable background subagent** is a durable child session with a series of Task-backed activations. `ctx.subagentControl` (`SubagentControlService` in [dsh-subagent-control](../../packages/subagent/subagent-control)) allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. On follow-up, the control service loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches a fully resolved resume request through `SubagentService.resume()` to `SubagentProvider.resume()`. The seam stays Task- and persistence-agnostic — descriptor lookup and Task association live only in the control service. `startContinuable()` returns a `ContinuableStart` (both identities), and `sendMessage()` returns a `SendMessageResult` reporting whether the message `steered` the running activation's existing Task or `started` a fresh one.
```ts type-equiv
/**
* The resolved continuable-child identity and durable composition record a
* control-service caller attaches to a start request.
*/
interface SubagentContinuation {
/** Control-allocated stable child session id, published verbatim. */
readonly sessionId: SessionId
/** Snapshotted descriptor persisted in the child log for cold resume. */
readonly descriptor: SubagentDescriptorData
}
```
```ts type-equiv
/**
* What a caller asks for when resuming a persisted continuable child. The
* control service loads the child log, folds and authorizes its descriptor,
* and passes this fully resolved request to
* {@link SubagentService.resume}, which dispatches to
* {@link SubagentProvider.resume}. The provider reconstructs the declared
* composition under the live parent's scope and drives one turn with `prompt`.
*/
interface SubagentResumeRequest {
/** The persisted child session id to resume. */
readonly sessionId: SessionId
/** The follow-up message that starts the resumed activation's turn. */
readonly prompt: ContentBlock[]
/**
* 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.
*/
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.
*/
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.
## 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.
@@ -142,7 +203,7 @@ interface SubagentStopReasonMap {
## A live run: `SubagentRun`
`SubagentRun` is the consumer-owned handle for a ready child. 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. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence.
`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. The optional strict `steer` method advertises live delivery by presence; cold resume deliberately does NOT live here (a disposed run cannot be reconstructed after restart) — it is `SubagentProvider.resume`.
```ts type-equiv
/**
@@ -177,15 +238,16 @@ interface SubagentRun {
*/
dispose(): Promise<void>
/**
* OPTIONAL (steering capability): send additional content to the running
* child between steps. Present only on providers that support live steering.
* OPTIONAL (strict live-steering capability): deliver additional content to
* the actively running child turn. STRICT means delivery joins the observed
* turn or fails — the implementation must synchronously require the child to
* be running with no asynchronous boundary before delivery, and must not
* fall back to a queue path that could start a new, untracked turn after
* this run has settled. Throws when the child is not running. A run
* represents one disposable activation, so it has no cold-resume operation;
* resuming a settled child goes through {@link SubagentProvider.resume}.
*/
sendMessage?(content: ContentBlock[]): void
/**
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): Promise<SubagentRun>
steer?(content: ContentBlock[]): void
}
```
@@ -221,10 +283,21 @@ interface SubagentProvider {
* promise rejects. Ownership transfers to the caller only on fulfillment.
*/
start(request: SubagentStartRequest): Promise<SubagentRun>
/**
* 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 `resume` dispatch and continuable starts 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.
*/
resume?(request: SubagentResumeRequest): Promise<SubagentRun>
}
```
`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. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
`start()` fulfills only with a ready run; `resume()` shares the same publication and lifecycle-observation contract. 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.
## In-process backends: depth and seed