27 KiB
Agent Note: Continuable background subagents
Status: implemented
English | 中文
Problem
The subagent tool treats each delegation as one owned SubagentRun: foreground calls and background Tasks collect the result and then dispose the run. Disposal bounds the number of live child Agents and releases their scoped services, listeners, and provider resources. The persisted child session may survive, but the parent has no durable catalog or tool path for discovering that child and starting another turn on it.
A Task, a run, and a child session have different lifetimes. A Task represents one background turn and has one terminal result. A SubagentRun owns one activation of a child. A persisted child session may contain many turns initiated by the parent or a human. Continuation must preserve per-run disposal rather than retain every historical child Agent in memory.
Decision
A continuable background subagent is a durable child session with a series of Task-backed activations. The child session id, transcript, lineage, and declared composition survive in persistence. Each initial or resumed activation creates a fresh Task, AgentHandle, and SubagentRun, drives one turn, collects its result, and disposes the run before the Task becomes terminal.
The Task's result and cancellation boundary belong to the child activation, not to whichever caller supplied its first message. Task access is authorized by the parent session id, while the Task registry retains the exact live parent Agent instance for notification and teardown. Parent and human messages therefore share one activation result while the parent remains its runtime owner:
durable child Session
activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose
activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose
activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose
Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. A provider supports persisted cold resume before its children are advertised as continuable — tool-subagent branches its background route on the mounted provider's resume capability — and ACP children remain one-shot until the deferred ACP continuation work below is complete.
The low-level ctx.subagents seam stays collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches start or resume, observes run lifecycle, and returns holder-owned runs. The SubagentControlService (ctx.subagentControl in @deepseek-ai/dsh-subagent-control) owns stable continuable-child ids, descriptor persistence and lookup by known child id, Task-backed activation, and message routing. The provider-bound @deepseek-ai/dsh-tool-subagent plugin and human-facing adapters call that control service for continuable background work; foreground one-shot delegation still calls ctx.subagents.start() directly. The globally named model tool is a thin adapter in @deepseek-ai/dsh-tool-subagent-control. Parent-to-child enumeration and list_agents belong to a separate durable-catalog proposal.
Task and cancellation ownership
The initial background delegation asks the control service to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls run.dispose() (through the control service's settleRun), and only then records the TaskOutcome; task_kill aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A failed required durability checkpoint rejects the run with stable code DURABILITY_FAILED and the backend failure as its cause; the control service records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume.
Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the settleRun() contract and bounds Task-owned live children by concurrent work rather than historical session count.
Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and task_output remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; kill, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to interactive side sessions, not this Task-owned lifecycle.
TaskService.start() rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads @deepseek-ai/dsh-tool-tasks; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations.
Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as killed; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one.
A cold-resume Task creates its activation-owned AbortController before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as failed. A persistence call that has no signal need not stop its underlying I/O, but the control service rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. task_kill and exact-owner disposal use this path even when provider resume has not returned a SubagentRun; Task settlement waits for rollback or run disposal and records killed only after the activation is quiescent.
Active run association
The control service keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public ManagedSubagent, admission reservation, or run-state machine.
For a continuable initial activation, the control service allocates the stable child session id before Task creation and passes it in the resolved provider start request (SubagentStartRequest.continuation); in-process spawn and fork publish that exact id instead of allocating one internally. The background tool acknowledgement exposes both identities as started subagent <childId> as task <taskId>. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an unmaterialized child: the caller holds a child id without a durable header and descriptor. Later by-id control operations report that id as unavailable (the started Task fails with that detail), and durable enumeration omits it.
Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with ctx.agents.get(childId). A registry Agent with no association, or a registry Agent different from the associated run.localAgent, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary.
Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed SubagentRun.steer capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be running and reject an already committed structured capture, then call Agent.steer() and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it admitted only after a successful pre-step has appended the message, captured the immutable request history, and committed step/start; terminal turn policy, cancellation, and disposal resolve pending receipts rejected. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before Agent.steer() so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, steer() rejects, send_message reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation.
The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing sendMessage during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction.
Model-facing send_message
The model receives one send_message(subagent_id, message) tool backed by SubagentControlService.sendMessage(). The control operation owns steer-or-resume orchestration and is distinct from the run's SubagentRun.steer?(), which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as { kind: 'coordinator', senderSessionId: parent.id }; the control service requires a caller-supplied MessageSource and carries it through both live steering and cold resume. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies { kind: 'user' }. The tool lives in the separately loaded @deepseek-ai/dsh-tool-subagent-control package so provider-bound @deepseek-ai/dsh-tool-subagent instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools.
- If the child has a running Task and live-steering capability, the service calls
run.steer(message, source)and returns the existing Task id; it creates no Task of its own. - If the child has no running Task,
send_messagecreates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. - If the active provider cannot accept live delivery, confirmed steering loses its admission race, or a live child exists outside the Task association,
send_messagefails rather than silently starting, resuming, or adopting an untracked turn.
The service result identifies the route as steered with the existing Task id or started with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller.
A delivered message has no independent result: its effect is reflected in the current Task's eventual result. A started follow-up has the fresh Task's result and existing task_output read path. The subagent layer adds no second completion injection.
Human input uses the same control operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one control-service contract rather than separate execution paths.
Durable child handle and cold resume
The control service snapshots every descriptor input with the seam's snapshotSubagentDescriptor() (built on snapshotJsonValue) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a one-shot agent/pre-step listener installed by the in-process driver — appends one model-hidden subagent/descriptor event after the initial child turn/start and before its first request; it carries no surfaceOp, remains outside model history, and reaches persistence with that turn's flush. The append-only log retains this non-surface event when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after seedLength, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent.
The versioned descriptor (SUBAGENT_DESCRIPTOR_VERSION in descriptor.ts) contains the subagent provider name, resolved child agentOptions.provider and agentOptions.model, and optional persona and toolFilter. It does not snapshot the merge-extensible AgentOptions object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits subagentDepth; cold resume relies on the persisted header's delegationDepth rather than reconstructing depth from the descriptor. outputSchema belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, cwd, parentSession, seedLength, and delegationDepth, while the persisted child transcript owns the fork seed and subsequent history. delegationDepthOf() takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget.
Cold resume cannot depend on an optional method of the old SubagentRun, because that run has been disposed and is not retained across process restart. SubagentRun has no resume operation: a run represents one disposable activation and exposes only activation-scoped operations. The former SubagentRun.sendMessage?() capability is named SubagentRun.steer?() so its confirmed live-only contract cannot be confused with service orchestration or the model-facing tool.
SubagentControlService's resume path loads the known child session, folds its descriptor, authorizes the persisted parentSession, and runs inside the Task it creates. It passes a fully resolved request, including the Task-owned cancellation signal, to the low-level SubagentService.resume(provider, request), whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by start. The selected SubagentProvider.resume?() owns transport-specific reconstruction (in-process: parent.ctx.agents.resume under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. SubagentControlService.sendMessage() chooses between the associated run's steer?() operation and this cold-resume path. Neither the low-level service nor a provider enumerates durable children or associates Tasks.
The background tool validates and snapshots descriptor inputs before calling TaskService.start(). A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final session flush after the child becomes idle and before reading the result; this retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the control service disposes the run, and the already-created Task settles as failed with the durability diagnosis in its detail. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork 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. Resuming a parent does not eagerly resume its children.
TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement AcpProvider.resume?() as spawn, initialize, loadSession, then prompt. The initial ACP run must verify initialize.agentCapabilities.loadSession, and every resumed process must use the same durable backend; replayed history from loadSession must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog.
Result and notification ownership
Every continuable child activation has exactly one Task and one TaskOutcome, regardless of whether the parent or a human supplied the first message. The generic Task reporting contract may inject at most one unsolicited completion notice to the retained parent owner while the Task is unreported; reads, waits, and cancellation may suppress it. Running delivery joins that activation and creates neither a second Task nor a second result. The child transcript remains the human-facing detailed record; Task output remains the parent-facing final result.
Task records and active-run associations are process-local. Persistence makes the child session resumable after restart, but does not recover an interrupted Task, its result, or its notification. Durable Task recovery is a separate concern.
Alternatives considered
Retain every background child after Task settlement. This is the Codex-style resident-session model: follow-up delivery is cheap, but historical children retain Agent scopes, session memory, listeners, and provider resources until an explicit residency limit or eviction policy removes them. Per-activation disposal uses persistence as the continuation boundary and preserves the current resource bound.
Let human turns run without Tasks. A parent message joining such a turn has no Task result or completion notice, and UI cancellation has unclear effects on the parent's contribution. Giving every activation one Task makes completion and cancellation properties of the child turn rather than its initiating caller.
Keep one Task for the lifetime of a child session. A terminal Task cannot naturally become running again, and one result cannot represent multiple turns. Fresh activation-scoped Tasks preserve the generic Task contract.
Create a Task for every message. Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task.
Split send_message and follow_up. Separate delivery operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle.
Keep resume?() on the disposed run. Retaining a disposed SubagentRun only to call resume() makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit.
Put control orchestration on SubagentService. This would let one service look up descriptors, associate Tasks, and dispatch providers, but would make the collection-agnostic provider seam depend on one consumer's persistence and Task policy. A separate control service keeps start/resume transport reusable by foreground and non-Task consumers while giving tools and UI one orchestration path.
Add explicit activation phases. Public starting/running/settling states could describe admission and cleanup precisely, but would add a lifecycle protocol the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases.
Testing
packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.tspins the continuable durability boundary: a permanent flush failure rejects withDURABILITY_FAILEDand its cause, a transient loop-checkpoint failure can succeed on the final confirmation, resume also confirms durability, and foreground runs remain best-effort.packages/subagent/subagent-control/tests/subagent-control.spec.tsdrives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id;task_killduring a run or during cold-resume lookup settleskilledafter quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once.packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.tspins thesend_messageschema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal.packages/subagent/tool-subagent/tests/tool-subagent.spec.tscovers the capability-branched background route: a resumable provider returns both ids through the control service and advertisessend_message, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud.- The keyless ACP snapshot scenario
subagent-continuable(examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered throughtask_outputwithout unconfirmed child output, and asend_messagefollow-up whose started Task fails with the id unavailable.
Consequences
- Every follow-up after settlement pays persistence load and scoped setup cost; in exchange, live children stay bounded by concurrent work rather than historical session count. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed.
- Two callers may still race a stopped child through paths outside the control service. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through the control service without a public lifecycle state machine.
- Driving a continuable child through the ordinary Agent API bypasses its Task association. The control service rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through
SubagentControlService.sendMessage(). - The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation.
- Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting.
- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, a failed final durability confirmation, or process exit before the first child flush leaves the Task failed and may leave an unmaterialized or stale child id; by-id control reports missing state as unavailable rather than retroactively changing the tool acknowledgement.
- Persisting explicit composition fields in the child log makes their lossless-JSON and compatibility contract part of resume. Later support for another composition input requires a deliberate descriptor-version change rather than implicitly persisting merge-extensible
AgentOptionsfields. - Task records and active-run associations are process-local even though child sessions are durable. Restart recovers the session, not in-flight work or its Task notification.