mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
docs: propose continuable background subagents
This commit is contained in:
@@ -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-21-continuable-background-subagents.md: f0fb441cab87010f544d9be7036518b6f3e41c77
|
||||
2026-07-21-continuable-background-subagents.zh.md: 0dfb5fe8837b51c9a0220fb32f9b1340202e8ae6
|
||||
@@ -0,0 +1,148 @@
|
||||
# Agent Note: Continuable background subagents
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-21-continuable-background-subagents.zh.md)
|
||||
|
||||
## 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.
|
||||
|
||||
## Proposal
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
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 current one-shot behavior. The first continuable implementation covers background in-process spawn and fork children. A provider must support persisted cold resume before its children are advertised as continuable; ACP children remain one-shot until the deferred ACP continuation work below is complete.
|
||||
|
||||
The low-level `ctx.subagents` seam remains collection-, Task-, and persistence-agnostic. It registers providers, validates and dispatches `start` or `resume`, observes run lifecycle, and returns holder-owned runs. A separate `SubagentControlService` 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` are specified separately by the [durable subagent catalog](2026-07-22-durable-subagent-catalog-and-list-agents.md).
|
||||
|
||||
### Task and cancellation ownership
|
||||
|
||||
The initial background delegation asks the control service to start the child and register its Task. Task settlement awaits the result, calls `run.dispose()`, 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.
|
||||
|
||||
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 existing `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. The first version therefore permits human interaction 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](2026-07-08-interactive-side-sessions.md), 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 and passes that signal through `SubagentControlService.resume()`, `SubagentService.resume()`, and `SubagentProvider.resume?()`. 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; 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, and durable enumeration omits it.
|
||||
|
||||
The first version admits every continuable child turn 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 strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `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 first version does not serialize two callers that concurrently observe a stopped child, nor does it model a separate settling phase between result production and disposal. Concurrent cold-resume attempts may both create Tasks, but the Agent registry permits only one same-session Agent to publish; a losing Task fails and its message is not delivered. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction.
|
||||
|
||||
Atomic process-local admission is on hold. The smallest follow-up would synchronously reserve the child before awaiting resume, conceptually with `Map<SessionId, Promise<SubagentRun>>`; later callers would await the same publication promise and then use strict live delivery. This would close duplicate cold resume without adding a public `ManagedSubagent` or explicit `starting`/`running`/`settling` protocol.
|
||||
|
||||
### 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 lives in a 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)` and returns the existing Task id; it creates no Task of its own.
|
||||
- If the child has no running Task, `send_message` creates 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, strict steering loses a race with Task settlement, or a live child exists outside the Task association, `send_message` fails 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 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution 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 and its header identifies the caller as the direct parent.
|
||||
|
||||
The versioned descriptor 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()`](../../../../packages/subagent/subagent/src/index.ts) 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. This proposal removes `SubagentRun.resume?()`: a run represents one disposable activation and exposes only activation-scoped operations. It also renames the existing `SubagentRun.sendMessage?()` capability to `SubagentRun.steer?()` so its strict live-only contract cannot be confused with service orchestration or the model-facing tool.
|
||||
|
||||
`SubagentControlService.resume()` loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and creates the Task. 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 and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag is added. `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. Child creation, first-turn persistence, or descriptor persistence failure disposes any published run and settles the already-created Task as `failed`; the model observes that failure through the ordinary Task completion or `task_output` path. The first implementation reconstructs in-process spawn and fork 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.
|
||||
|
||||
### Implementation boundary
|
||||
|
||||
One implementation PR delivers this proposal: stable child-id allocation and provider handoff, the child-session descriptor event, `SubagentControlService`, in-process provider cold resume, existing background-delegation routing, strict spawn/fork steering, active-run association, human message routing, and the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package with its `send_message` tool. Parent-to-child enumeration and `list_agents` consume this durable child-handle contract but remain a separate feature and PR. ACP continuation is a separate provider follow-up after the child-specific advertisement contract above is resolved.
|
||||
|
||||
## 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 strict 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 that the first implementation does not otherwise need. The on-hold promise reservation closes duplicate process-local cold resume without exposing those phases.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Initial and resumed continuable activations create a fresh Task and dispose their run before that Task becomes terminal.
|
||||
- Opening a persisted child for display creates no Agent activation; human input under a loaded parent starts or joins a Task-backed activation.
|
||||
- Human and parent messages delivered to one running activation share its Task result and cancellation outcome.
|
||||
- Cancelling a human-started activation aborts and disposes its run and settles the Task as `killed`; its completion notice follows the generic at-most-one reporting contract and may be suppressed when the Task is already reported.
|
||||
- A cold-resume Task owns its AbortSignal before descriptor lookup; cancellation during lookup or provider resume prevents later publication or cancels the published run, and Task settlement waits for rollback or disposal quiescence before reporting `killed`.
|
||||
- A human-facing adapter attaches a Task control surface before accepting child input; absence of a surface fails clearly instead of starting untracked work.
|
||||
- `send_message` delivers to a running child without creating a Task and cold-resumes a stopped child into a fresh Task-backed activation.
|
||||
- `send_message` reports whether it `steered` an existing Task or `started` a new Task, including the relevant Task id, and reports a failure as not delivered.
|
||||
- Initial continuable delegation allocates its child id before Task creation, passes that id through provider publication, and returns both the stable child id and activation Task id to the model.
|
||||
- Spawn and fork implement strict `SubagentRun.steer` behavior with no asynchronous boundary between the running check and `Agent.steer()`; live delivery cannot fall back to an untracked Agent turn.
|
||||
- If strict steering loses a race with Task settlement, `send_message` reports the message as not delivered and does not cold-resume within that call.
|
||||
- `SubagentRun` has no cold-resume operation; `SubagentControlService.sendMessage()` dispatches active delivery to `run.steer?()` and inactive delivery through low-level `SubagentService.resume()` to `SubagentProvider.resume?()`.
|
||||
- The `SubagentRun.sendMessage?()` to `steer?()` rename and the background activation route update the seam module JSDoc, package READMEs, core-data-structures catalog, and `tool-subagent` `settleRun` ownership documentation and tests in the same PR.
|
||||
- `SubagentService` remains unaware of Tasks and durable descriptors; `SubagentControlService` owns continuable activation, authorization, descriptor lookup by known child id, and Task/run association for tool and UI consumers.
|
||||
- Every supported continuable child turn installs its Task association before provider awaits and retains it through run disposal; by-id routing rejects a live `ctx.agents.get(childId)` unless the association exists and its `run.localAgent` is that exact Agent.
|
||||
- A known persisted child id can be authorized and lazily reconstructed after parent resume with equivalent declared composition under the resumed parent's scope; fork resume uses only the child's persisted transcript and never re-forks current parent history.
|
||||
- Descriptor inputs are snapshotted before Task creation; a versioned model-hidden descriptor event is turn-enclosed in the child session, excluded from the surface, retained across compaction, and folded only after the child header passes direct-parent authorization. The descriptor omits `subagentDepth`, and resumed depth uses the persisted header as its monotone floor.
|
||||
- Invalid descriptor JSON rejects the tool without creating a Task, while asynchronous child or descriptor persistence failure disposes the run and settles the returned Task as `failed`.
|
||||
- Provider-bound delegation tools remain in `@deepseek-ai/dsh-tool-subagent`; the globally named `send_message` tool registers once from `@deepseek-ai/dsh-tool-subagent-control`.
|
||||
- Each activation produces one Task result and at most one unsolicited existing Task completion notice; reads, waits, or cancellation may suppress that notice, and steering and the subagent layer add no duplicate notification.
|
||||
- Tests document that concurrent stopped-child admission is not atomic: one same-session publication wins, a losing Task fails, and the losing message is not reported as delivered.
|
||||
- Keyless package tests cover Task ownership, disposal ordering, human start and cancellation, running delivery, cold follow-up, descriptor rejection and rollback, known-id reconstruction, scope reconstruction, and terminal cleanup. Model-visible tool and transcript changes have runnable snapshot coverage.
|
||||
|
||||
## Risks
|
||||
|
||||
- Every follow-up after settlement pays persistence load and scoped setup cost. Continuable creation fails clearly when persistence is unavailable or the stored composition cannot be reconstructed.
|
||||
- Two callers may concurrently observe a stopped child and start competing resumes. The Agent registry prevents duplicate same-session publication, but a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. The first version does not claim atomic or exactly-once admission; the on-hold process-local promise reservation can close duplicate cold resume without requiring 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, persistence failure, or process exit before the first child flush may leave an unmaterialized child id; by-id control reports it as unavailable and durable enumeration omits it rather than retroactively changing the tool result.
|
||||
- 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 `AgentOptions` fields.
|
||||
- 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.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Agent Note: 可继续的后台 subagent
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-21-continuable-background-subagents.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。
|
||||
|
||||
Task、run 和 child 会话具有不同的生命周期。一个 Task 表示一轮后台执行,并且只有一个终态结果。一个 `SubagentRun` 拥有 child 的一次激活。一个持久化 child 会话可以包含多个由 parent 或用户发起的轮次。继续执行必须保留逐 run dispose 的约定,而不能把所有历史 child agent 都留在内存中。
|
||||
|
||||
## 提案
|
||||
|
||||
一个可继续的后台 subagent,是由一系列 Task 支撑的短期激活共同组成的持久化 child 会话。child session id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。
|
||||
|
||||
Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent session id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果:
|
||||
|
||||
```text
|
||||
durable child Session
|
||||
activation 1: Task 1 -> SubagentRun -> AgentHandle -> dispose
|
||||
activation 2: Task 2 -> SubagentRun -> AgentHandle -> dispose
|
||||
activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose
|
||||
```
|
||||
|
||||
前台委派保持当前的一次性行为。第一版可继续实现覆盖进程内 spawn 和 fork child。提供方只有支持从持久化存储恢复后,才能将其 child 标记为可继续;在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。
|
||||
|
||||
底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`@deepseek-ai/dsh-subagent-control` 中单独的 `SubagentControlService` 负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 由单独的[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)规定。
|
||||
|
||||
### Task 与取消的所有权
|
||||
|
||||
初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。
|
||||
|
||||
后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留现有 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。
|
||||
|
||||
用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。第一版仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。
|
||||
|
||||
如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
|
||||
|
||||
取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。
|
||||
|
||||
从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`,并通过 `SubagentControlService.resume()`、`SubagentService.resume()` 和 `SubagentProvider.resume?()` 逐层传递其信号。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。
|
||||
|
||||
### 活跃 run 关联
|
||||
|
||||
控制服务在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。
|
||||
|
||||
对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent <childId> as task <taskId>`。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它。
|
||||
|
||||
第一版要求每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。
|
||||
|
||||
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
|
||||
|
||||
第一版不会串行化两个同时观察到 child 已停止的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。并发的 cold resume 尝试可能都会创建 Task,但 agent 注册表只允许一个相同会话的 agent 完成发布;失败的 Task 不会送达其消息。发送也可能因与启动、取消、完成或清理发生竞态而失败。本提案明确接受这些限制,不为此引入更大的生命周期抽象。
|
||||
|
||||
原子的进程内准入暂缓实现。最小的后续方案是在等待 resume 之前同步预留 child,概念上使用 `Map<SessionId, Promise<SubagentRun>>`;后续调用方等待同一个发布 promise,再使用严格的在线消息功能。这样无需添加公开的 `ManagedSubagent` 或显式 `starting`/`running`/`settling` 协议,即可消除重复的 cold resume。
|
||||
|
||||
### 面向模型的 `send_message`
|
||||
|
||||
模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。
|
||||
|
||||
- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。
|
||||
- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。
|
||||
- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。
|
||||
|
||||
服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。
|
||||
|
||||
发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。
|
||||
|
||||
用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。
|
||||
|
||||
### 持久化 child handle 与从持久化存储恢复
|
||||
|
||||
控制服务在创建 Task 前,通过 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution 会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后能得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。
|
||||
|
||||
版本化描述符包含 subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。
|
||||
|
||||
从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。本提案删除 `SubagentRun.resume?()`:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。本提案还将现有 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。
|
||||
|
||||
`SubagentControlService.resume()` 会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并创建 Task。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建,并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。
|
||||
|
||||
后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。第一版会在当前已加载的 parent 作用域下重建进程内 spawn 和 fork 组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。
|
||||
|
||||
TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。
|
||||
|
||||
### 结果与通知所有权
|
||||
|
||||
每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告契约最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。
|
||||
|
||||
Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可在重启后恢复,但不会恢复中断的 Task、其结果或通知。持久化 Task 恢复属于另一个问题。
|
||||
|
||||
### 实现边界
|
||||
|
||||
一个实现 PR 会交付本提案:稳定 child id 的分配与提供方交接、child 会话描述符事件、`SubagentControlService`、进程内提供方从持久化存储恢复、现有后台委派路由、严格的 spawn/fork steering、活跃 run 关联、用户消息路由,以及单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包及其 `send_message` 工具。parent 到 child 的枚举与 `list_agents` 使用这份持久化 child handle 契约,但仍是单独的功能和 PR。解决上述按 child 声明支持的契约后,再通过单独的提供方改动支持 ACP 继续执行。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**在 Task 结算后保留所有后台 child。** 这是 Codex 风格的常驻会话模型:发送后续消息成本较低,但历史 child 会持续占用 agent 作用域、会话内存、监听器和提供方资源,直至显式常驻数量上限或淘汰策略将其移除。逐激活 dispose 使用持久化作为继续执行边界,同时保留当前的资源上限。
|
||||
|
||||
**允许用户轮次不使用 Task。** parent 消息加入此类轮次后,没有对应的 Task 结果或完成通知;UI 取消对 parent 所发消息的影响也不明确。让每次激活都拥有一个 Task,可使完成与取消成为 child 轮次的属性,而不是初始调用方的属性。
|
||||
|
||||
**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 契约。
|
||||
|
||||
**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。
|
||||
|
||||
**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。
|
||||
|
||||
**在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。
|
||||
|
||||
**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。
|
||||
|
||||
**增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入第一版实现并不需要的生命周期协议。暂缓实现的 promise 预留无需暴露这些阶段,即可消除进程内重复的 cold resume。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 初始及恢复后的可继续激活都会创建新 Task,并在该 Task 进入终态前 dispose 对应 run。
|
||||
- 打开持久化 child 仅用于展示时,不会创建 agent 激活;在 parent 已加载时,用户输入会启动或加入一个由 Task 支撑的激活。
|
||||
- 用户消息和 parent 消息发送到同一个运行中激活后,共享其 Task 结果和取消结果。
|
||||
- 取消用户启动的激活会中止并 dispose 对应 run,将 Task 结算为 `killed`;其完成通知遵循通用的至多一次报告契约,并且在 Task 已标记为已报告时可能被抑制。
|
||||
- 从持久化存储恢复的 Task 在描述符查找前就持有其 AbortSignal;查找描述符或执行提供方恢复期间发生取消时,系统不得在之后发布 run,若 run 已发布则会取消它。Task 只有在回滚或 dispose 完成、激活完全停稳后,才结算为 `killed`。
|
||||
- 用户界面适配器在接受 child 输入前会附加 Task 控制面;缺少控制面时明确失败,而不会启动未受跟踪的工作。
|
||||
- `send_message` 向运行中的 child 发送消息时不会创建 Task;向已停止的 child 发送消息时,会从持久化存储恢复并创建新的 Task 激活。
|
||||
- `send_message` 会以 `steered` 报告消息已加入现有 Task,或以 `started` 报告已启动新 Task,并携带相应 task id;失败时会报告消息未送达。
|
||||
- 初始可继续委派在创建 Task 前分配 child id,通过提供方发布路径传递该 id,并向模型返回稳定的 child id 与当前激活的 Task id。
|
||||
- spawn 和 fork 实现严格的 `SubagentRun.steer` 行为;检查运行状态与调用 `Agent.steer()` 之间不存在异步边界,在线消息不会 fallback 到未受跟踪的 Agent 轮次。
|
||||
- 严格 steering 在与 Task 结算的竞态中失败时,`send_message` 会报告消息未送达,而且不会在该次调用中从持久化存储恢复。
|
||||
- `SubagentRun` 不提供从持久化存储恢复的操作;`SubagentControlService.sendMessage()` 将活跃消息分发至 `run.steer?()`,将非活跃消息经由底层 `SubagentService.resume()` 分发至 `SubagentProvider.resume?()`。
|
||||
- `SubagentRun.sendMessage?()` 到 `steer?()` 的重命名和后台激活路由,会在同一 PR 中同步更新 seam 模块 JSDoc、各包 README、core-data-structures 目录,以及 `tool-subagent` 中 `settleRun` 的所有权文档和测试。
|
||||
- `SubagentService` 不感知 Task 与持久化描述符;`SubagentControlService` 负责可继续激活、鉴权、按已知 child id 查找描述符,以及工具和 UI 消费方使用的 Task/run 关联。
|
||||
- 每个受支持的可继续 child 轮次都会在等待提供方之前安装 Task 关联,并保留该关联直到 run dispose 完成;按 id 路由会拒绝存活的 `ctx.agents.get(childId)`,除非关联已存在,且其 `run.localAgent` 就是该 Agent。
|
||||
- parent 恢复后,系统可以对已知的持久化 child id 鉴权,并在恢复后的 parent 作用域下,以等价的声明式组合配置按需重建该 child;恢复 fork 时只使用 child 的持久化 transcript,绝不重新 fork parent 的当前历史。
|
||||
- 描述符输入会在创建 Task 前建立快照;带版本、对模型隐藏的描述符事件位于 child 会话轮次内,不属于 surface,在压缩后仍保留,并且只有在 child header 通过直接 parent 鉴权后才会被归并。描述符省略 `subagentDepth`,恢复时的深度以持久化 header 中的值为单调下界。
|
||||
- 描述符 JSON 无效会拒绝工具调用且不创建 Task,异步 child 创建或描述符持久化失败则会 dispose 对应 run,并将已经返回的 Task 结算为 `failed`。
|
||||
- 按提供方绑定的委派工具仍位于 `@deepseek-ai/dsh-tool-subagent`;全局命名的 `send_message` 工具由 `@deepseek-ai/dsh-tool-subagent-control` 注册一次。
|
||||
- 每次激活只产生一个 Task 结果和至多一条现有 Task 主动完成通知;读取、等待或取消可能抑制该通知,steering 和 subagent 层不会添加重复通知。
|
||||
- 测试记录已停止 child 的并发准入并非原子操作:一个相同会话的发布成功,失败的 Task 进入失败状态,且其消息不会被报告为已送达。
|
||||
- 无密钥包测试覆盖 Task 所有权、dispose 顺序、用户启动和取消、运行中消息、持久化后续轮次、描述符拒绝与回滚、按已知 id 重建、作用域重建,以及所有终态下的清理。面向模型的工具及 transcript 变更具有可运行的快照覆盖。
|
||||
|
||||
## 风险
|
||||
|
||||
- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。
|
||||
- 两个调用方可能同时观察到 child 已停止,并启动相互竞争的恢复。agent 注册表会阻止相同会话的重复发布,但失败的 Task 不会送达其消息。消息也可能与取消、终态发布或 run dispose 发生竞态。第一版不承诺原子准入或恰好执行一次语义;暂缓实现的进程内 promise 预留无需公开生命周期状态机,即可消除重复的 cold resume。
|
||||
- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。
|
||||
- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。
|
||||
- 用户交互要求 Task 注册表中作为 owner 的那个 parent agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。
|
||||
- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。
|
||||
- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。
|
||||
- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。
|
||||
Reference in New Issue
Block a user