From b5731dfdaf81d7b40796ca20a543d75a164ee318 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:17:53 +0800 Subject: [PATCH 01/90] docs: propose continuable background subagents --- ...continuable-background-subagents.i18n.yaml | 6 + ...-07-21-continuable-background-subagents.md | 148 ++++++++++++++++++ ...-21-continuable-background-subagents.zh.md | 148 ++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md create mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml new file mode 100644 index 0000000000..ef603b1543 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.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-21-continuable-background-subagents.md: f0fb441cab87010f544d9be7036518b6f3e41c77 +2026-07-21-continuable-background-subagents.zh.md: 0dfb5fe8837b51c9a0220fb32f9b1340202e8ae6 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md new file mode 100644 index 0000000000..f0fb441cab --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md @@ -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 as task `. 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>`; 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. diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md new file mode 100644 index 0000000000..0dfb5fe883 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md @@ -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 as task `。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>`;后续调用方等待同一个发布 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 通知。 From 76ff841279a0669f7046c563bcdb352ddbcf24f9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 22 Jul 2026 16:25:36 +0800 Subject: [PATCH 02/90] docs: keep continuable RFC self-contained --- .../2026-07-21-continuable-background-subagents.i18n.yaml | 4 ++-- .../feature/2026-07-21-continuable-background-subagents.md | 2 +- .../feature/2026-07-21-continuable-background-subagents.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml index ef603b1543..260210e81f 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/proposed/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 -2026-07-21-continuable-background-subagents.md: f0fb441cab87010f544d9be7036518b6f3e41c77 -2026-07-21-continuable-background-subagents.zh.md: 0dfb5fe8837b51c9a0220fb32f9b1340202e8ae6 +2026-07-21-continuable-background-subagents.md: 9d105743cba2045f1797a408a96b8391ccc9eb82 +2026-07-21-continuable-background-subagents.zh.md: 73428fa3595422b6743383526438f3a81a863100 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md index f0fb441cab..9d105743cb 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md @@ -25,7 +25,7 @@ durable child Session 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). +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` belong to a separate durable-catalog proposal. ### Task and cancellation ownership diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md index 0dfb5fe883..73428fa359 100644 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md @@ -25,7 +25,7 @@ durable child Session 前台委派保持当前的一次性行为。第一版可继续实现覆盖进程内 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)规定。 +底层 `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` 属于单独的持久化目录提案。 ### Task 与取消的所有权 From 99a778d63fe3d2df11a5e608a649021a09a6b1e9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:07:38 +0800 Subject: [PATCH 03/90] 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. --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 124 ++++ ...-21-continuable-background-subagents.zh.md | 124 ++++ ...-07-21-continuable-background-subagents.md | 148 ----- ...-21-continuable-background-subagents.zh.md | 148 ----- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.md | 6 + docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 60 +- docs/core-data-structures/subagent.md | 103 +++- docs/event-producer-consumer.md | 10 +- docs/module-graph.md | 31 +- docs/persistence-catalog.md | 17 + docs/tool-catalog.md | 35 +- examples/acp-agent/composition.md | 6 + examples/acp-agent/cordis.yml | 9 + examples/acp-agent/tests/acp.snapshot.ts | 5 + .../system-prompt.expected.md | 21 +- .../tool-schemas.expected.json | 29 +- .../both-mode-turn/tool-schemas.expected.json | 29 +- .../code-mode-turn/system-prompt.expected.md | 21 +- .../lsp-definition/tool-schemas.expected.json | 29 +- .../pty-tools/tool-schemas.expected.json | 29 +- .../tool-schemas.expected.json | 29 +- .../snapshots/subagent-continuable/input.json | 14 + .../subagent-continuable/session.1.jsonl | 17 + .../subagent-continuable/session.jsonl | 57 ++ .../stdout.expected.jsonl | 4 + .../text-turn/tool-schemas.expected.json | 29 +- .../web-fetch/tool-schemas.expected.json | 29 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- examples/package.json | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 48 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 14 +- packages/subagent/README.zh.md | 14 +- packages/subagent/subagent-control/README.md | 37 ++ .../subagent/subagent-control/package.json | 55 ++ .../subagent/subagent-control/src/index.ts | 442 ++++++++++++++ .../subagent-control/src/invariant.ts | 32 ++ .../tests/subagent-control.spec.ts | 539 ++++++++++++++++++ .../subagent/subagent-control/tsconfig.json | 39 ++ packages/subagent/subagent-fork/README.md | 1 - packages/subagent/subagent-fork/src/index.ts | 11 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 15 +- .../subagent/subagent-inprocess/README.zh.md | 15 +- .../subagent/subagent-inprocess/src/index.ts | 124 +++- packages/subagent/subagent-spawn/README.md | 1 - packages/subagent/subagent-spawn/src/index.ts | 10 +- .../tests/subagent-spawn.spec.ts | 11 +- packages/subagent/subagent/README.md | 39 +- packages/subagent/subagent/src/descriptor.ts | 116 ++++ packages/subagent/subagent/src/index.ts | 68 ++- packages/subagent/subagent/src/types.ts | 86 ++- .../subagent/tool-subagent-control/README.md | 40 ++ .../tool-subagent-control/package.json | 55 ++ .../tool-subagent-control/src/index.ts | 74 +++ .../tool-subagent-control/src/invariant.ts | 30 + .../tests/tool-subagent-control.spec.ts | 153 +++++ .../tool-subagent-control/tsconfig.json | 33 ++ .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/README.zh.md | 4 +- packages/subagent/tool-subagent/package.json | 6 + packages/subagent/tool-subagent/src/index.ts | 173 +++--- .../tool-subagent/tests/tool-subagent.spec.ts | 121 ++-- packages/subagent/tool-subagent/tsconfig.json | 3 + pnpm-lock.yaml | 123 ++++ python/sdk-runtime/package.json | 2 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-cordis-catalog.ts | 4 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-tool-catalog.ts | 21 + scripts/type-equiv.manifest.json | 10 + tsconfig.host.json | 2 + 83 files changed, 3167 insertions(+), 627 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-07-21-continuable-background-subagents.i18n.yaml (61%) create mode 100644 .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md create mode 100644 .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md delete mode 100644 .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl create mode 100644 packages/subagent/subagent-control/README.md create mode 100644 packages/subagent/subagent-control/package.json create mode 100644 packages/subagent/subagent-control/src/index.ts create mode 100644 packages/subagent/subagent-control/src/invariant.ts create mode 100644 packages/subagent/subagent-control/tests/subagent-control.spec.ts create mode 100644 packages/subagent/subagent-control/tsconfig.json create mode 100644 packages/subagent/subagent/src/descriptor.ts create mode 100644 packages/subagent/tool-subagent-control/README.md create mode 100644 packages/subagent/tool-subagent-control/package.json create mode 100644 packages/subagent/tool-subagent-control/src/index.ts create mode 100644 packages/subagent/tool-subagent-control/src/invariant.ts create mode 100644 packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts create mode 100644 packages/subagent/tool-subagent-control/tsconfig.json diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml similarity index 61% rename from .agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index 260210e81f..d8bdceb531 100644 --- a/.agents/notes/proposed/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 -2026-07-21-continuable-background-subagents.md: 9d105743cba2045f1797a408a96b8391ccc9eb82 -2026-07-21-continuable-background-subagents.zh.md: 73428fa3595422b6743383526438f3a81a863100 +2026-07-21-continuable-background-subagents.md: 25ae582b129b2e2dc4a34c6fb3c0247aa644677a +2026-07-21-continuable-background-subagents.zh.md: f7a09ce0519874dad8b32835d0b43914a37350c8 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 new file mode 100644 index 0000000000..25ae582b12 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -0,0 +1,124 @@ +# Agent Note: Continuable background subagents + +Status: implemented + +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. + +## 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: + +```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 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. Task settlement awaits the 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. + +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](../../proposed/feature/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; 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 as task `. 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 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 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 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)` 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 the seam's `snapshotSubagentDescriptor()` (built on [`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 — 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](../../../../packages/subagent/subagent/src/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()`](../../../../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. `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 strict 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. 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. 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 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 the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases. + +## Testing + +- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the 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.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_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, `task_output` collection, and a `send_message` follow-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, 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. 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 new file mode 100644 index 0000000000..f7a09ce051 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -0,0 +1,124 @@ +# Agent Note: 可继续的后台 subagent + +Status: implemented + +[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 标记为可继续——`tool-subagent` 会依据所挂载提供方的 `resume` 功能对其后台路由进行分支——在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 + +底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`SubagentControlService`(`@deepseek-ai/dsh-subagent-control` 中的 `ctx.subagentControl`)负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 + +### Task 与取消的所有权 + +初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `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](../../proposed/feature/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`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 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,并通过已完全解析的提供方启动请求(`SubagentStartRequest.continuation`)传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 + +每个可继续 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 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 + +### 面向模型的 `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 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动安装的一次性 `agent/pre-step` 监听器——会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 + +版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 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` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 + +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 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 恢复属于另一个问题。 + +## 已考虑的替代方案 + +**在 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` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 + +## 测试 + +- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、`task_output` 结果收集,以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 + +## 影响 + +- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 +- 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 +- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 +- 用户交互要求作为 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 通知。 diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md deleted file mode 100644 index 9d105743cb..0000000000 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.md +++ /dev/null @@ -1,148 +0,0 @@ -# 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` 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. 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 as task `. 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>`; 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. diff --git a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md deleted file mode 100644 index 73428fa359..0000000000 --- a/.agents/notes/proposed/feature/2026-07-21-continuable-background-subagents.zh.md +++ /dev/null @@ -1,148 +0,0 @@ -# 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` 属于单独的持久化目录提案。 - -### 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 as task `。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>`;后续调用方等待同一个发布 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 通知。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e305eb5f42..49febba2a9 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: 75340b6fb3e4e109974bcb9d9ccabec004d853e1 -architecture.zh.md: 0955a40f3571fe9146c75a6b1094f945fcc5219c +architecture.md: 0e78d7f9157e55ab1c5b6f518ef723e61237446e +architecture.zh.md: 27498c0d36ea54e6c952e0c1264b191d1448a554 diff --git a/docs/architecture.md b/docs/architecture.md index 75340b6fb3..0e78d7f915 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `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 | +| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | continuable-child Task-backed activation and steer-or-resume routing | | `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 0955a40f35..27498c0d36 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -39,6 +39,7 @@ | `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) | 具名委托提供方 | +| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | 可继续子 agent 的 Task 化 activation,以及 steer 或恢复路由 | | `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 d31ee37d8e..946091c2c7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -136,6 +136,8 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_tool_ralph["tool-ralph"] + svc_subagentControl["ctx.subagentControl
Continuable-subagent control service"] + pkg_tool_subagent_control["tool-subagent-control"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tasks_local["tasks-local"] @@ -223,6 +225,7 @@ flowchart LR pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage + pkg_subagent --> svc_subagentControl pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -311,6 +314,8 @@ flowchart LR svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_workspace + svc_subagentControl --> pkg_tool_subagent + svc_subagentControl --> pkg_tool_subagent_control svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subprocess --> pkg_bash_local @@ -390,6 +395,7 @@ flowchart LR | `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-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | +| `ctx.subagentControl` | `core` | [`subagent`](../packages/subagent/subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | - | Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages. | | `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 ad12c61134..7980ef4136 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1928,7 +1928,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:27`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -2343,10 +2343,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subagent-control` — requires `subagents` · `tasks` · `agents` ([`packages/subagent/subagent-control/src/index.ts`](../packages/subagent/subagent-control/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagentControl` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1f9386fe39..fa8a961f19 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -795,7 +795,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:150`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -812,7 +812,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:124`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -827,7 +827,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:130`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -849,7 +849,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:141`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..0e83259ea2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,6 +1946,50 @@ async closeAll(): Promise Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) +## `ctx.subagentControl` — `SubagentControlService` + +The continuable-subagent orchestration service. Tool schema and UI adapters are consumers of this one contract: parent and human messages route through sendMessage and share one activation result and cancellation boundary, while foreground one-shot delegation keeps calling `ctx.subagents.start()` directly. + +```ts cordis-catalog +/** + * 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. + */ +startContinuable(spec: ContinuableStartSpec): ContinuableStart + +/** + * Deliver one message to 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. A throw 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. + * @param message - the content to deliver. + * @returns whether the message `steered` the existing Task or `started` a new one. + */ +sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult +``` + +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) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/subagent/subagent-control/src/index.ts:152`](../../packages/subagent/subagent-control/src/index.ts) + ## `ctx.subagents` — `SubagentService` Named provider registry and capability-checked start surface. @@ -1983,11 +2027,23 @@ list(): string[] * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise + +/** + * Resume a persisted continuable child through the named provider's + * `resume` capability, with the same run lifecycle observation as + * {@link start}. The caller (the control service) has already loaded the + * child, folded its descriptor, and authorized the parent; this method owns + * only capability-checked dispatch. + * @param name - the provider recorded in the child's descriptor. + * @param request - the fully resolved resume request. + * @returns the fresh holder-owned run for the resumed activation. + */ +async resume(name: string, request: SubagentResumeRequest): Promise ``` -Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:181`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:191`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2497dbab9c..ec69056602 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -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 /** - * 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 + steer?(content: ContentBlock[]): void } ``` @@ -221,10 +283,21 @@ interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ 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 `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 } ``` -`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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f2007c814a..ce1e0f1310 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../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) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`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:150`](../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:124`](../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:130`](../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:141`](../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/module-graph.md b/docs/module-graph.md index 5ba907f02f..d880453727 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,11 +65,13 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] + pkg_subagent_control["subagent-control"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] pkg_tool_subagent["tool-subagent"] + pkg_tool_subagent_control["tool-subagent-control"] end subgraph group_web["packages/web"] pkg_tool_web["tool-web"] @@ -890,6 +892,13 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess + pkg_subagent_control --> pkg_agent + pkg_subagent_control --> pkg_invariants + pkg_subagent_control --> pkg_llm + pkg_subagent_control --> pkg_session + pkg_subagent_control --> pkg_session_persistence + pkg_subagent_control --> pkg_subagent + pkg_subagent_control --> pkg_tasks pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -899,12 +908,6 @@ flowchart TD pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_subagent_inprocess --> pkg_user_approval - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_tasks - pkg_tool_subagent --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client pkg_repository_plugin --> pkg_paths @@ -1011,6 +1014,18 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_subagent_control + pkg_tool_subagent --> pkg_tasks + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent_control + pkg_tool_subagent_control --> pkg_tools pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm @@ -1211,8 +1226,8 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-control`](../packages/subagent/subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -1225,6 +1240,8 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-control`](../packages/subagent/subagent-control), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent-control`](../packages/subagent/subagent-control), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b02dd1d6be..ec0e0c7a3e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -516,6 +516,23 @@ Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/ Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) +### `subagent/*` + +#### `subagent/descriptor` — log-only + +```ts persistence-catalog +/** + * Durable declared composition of a continuable subagent child, appended + * once by the establishing provider inside the child's initial turn, + * before its first request. Log-only: it carries no `surfaceOp`, never + * enters model history, and the append-only log retains it when + * compaction replaces surface history. + */ +'subagent/descriptor': SubagentDescriptorData +``` + +Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) + ### `todo/*` #### `todo/write` — log-only diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7d6fa79dea..0a316bc636 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,6 +31,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagentControl` | `tool/call`, `tool/result`, `child session events through the control service` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1116,7 +1117,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent` -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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. ```json { @@ -1132,7 +1133,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -1146,6 +1147,36 @@ Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/to The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. +## `@deepseek-ai/dsh-tool-subagent-control` + +### `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. + +```json +{ + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] +} +``` + +Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) + +The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. + ## `@deepseek-ai/dsh-tool-tasks` ### `task_kill` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b4a3236920..22adf09555 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -39,6 +39,10 @@ flowchart LR cfg --> plugin_acp_subagent_spawn plugin_acp_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_acp_subagent_fork + plugin_acp_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_acp_subagent_control + plugin_acp_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_acp_tool_subagent_control plugin_acp_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_acp_tool_subagent plugin_acp_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -79,6 +83,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6f9f000182..e45c56c92d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -96,6 +96,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the separately loaded control tool registers the +# one global `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b652f09931..aa4bad392c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -213,6 +213,11 @@ const SCENARIOS: Scenario[] = [ { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { 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, task_output collects the + // child result after settlement, and send_message to an unknown subagent id + // starts a follow-up task that settles failed with the id unavailable. + { name: 'subagent-continuable', hasModelTurn: true, recorded: false }, { name: 'subagent-depth-two-rejection', hasModelTurn: true, 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 97f0561a37..7df756ee99 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,27 +110,34 @@ 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_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** 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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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. */ @@ -309,6 +316,10 @@ interface ToolOutputMap { }[]; totalLines: number; }; + send_message: { + route: "steered" | "started"; + taskId: string; + }; skill: { name: string; provider: string; @@ -327,6 +338,7 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; @@ -335,6 +347,7 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; + 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 314b24e2be..5f8e31fe9b 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 @@ -237,6 +237,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -255,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -269,7 +290,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -280,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -294,7 +315,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 b61d7bf623..1a7e813d7c 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 @@ -180,6 +180,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -198,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -212,7 +233,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -223,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -237,7 +258,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 150c53d68e..31e8cdce23 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,27 +93,34 @@ 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_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** 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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 background task and return its id; collect with task_output or stop with task_kill. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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. */ @@ -280,6 +287,10 @@ interface ToolOutputMap { }[]; totalLines: number; }; + send_message: { + route: "steered" | "started"; + taskId: string; + }; skill: { name: string; provider: string; @@ -298,6 +309,7 @@ interface ToolOutputMap { subagent: { kind: "background"; taskId: string; + subagentId?: string; } | { kind: "foreground"; runId: string; @@ -306,6 +318,7 @@ interface ToolOutputMap { subagent_fork: { kind: "background"; taskId: string; + 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 9b5925605c..517c9b1d71 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 @@ -196,6 +196,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -214,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -228,7 +249,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -239,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -253,7 +274,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 8e093db8bd..abc3e13256 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 @@ -159,6 +159,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -177,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 beb93c6b53..2ac976d621 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 @@ -159,6 +159,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "session_event_read", "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", @@ -381,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -395,7 +416,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -406,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -420,7 +441,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json new file mode 100644 index 0000000000..7fd4a2c3e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "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." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl new file mode 100644 index 0000000000..c349369a85 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"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":"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":2,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"subagent/descriptor","seq":3,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"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"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl new file mode 100644 index 0000000000..e9e859d905 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -0,0 +1,57 @@ +{"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":"CHILD_OK\n[status: completed]"}],"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, SubagentControlError: 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, SubagentControlError: 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"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} 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 01ac777a42..47439bfdb0 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 @@ -159,6 +159,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -177,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up 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 70940f8907..d1a60f6f92 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 @@ -159,6 +159,27 @@ ] } }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -177,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -191,7 +212,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ @@ -202,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 return a task id; collect with `task_output` and stop 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 continuable background subagent: you receive its subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.", "parameters": { "type": "object", "properties": { @@ -216,7 +237,7 @@ }, "run_in_background": { "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." } }, "required": [ diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index d9a20e60c7..2a060ef30b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 5e7dd32bb6..b092257bbd 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 1f9f94fa0c..487541609a 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index b187ff75cc..4da592774f 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"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."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/examples/package.json b/examples/package.json index 97e7918361..fb105d2ad6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", + "@deepseek-ai/dsh-subagent-control": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", "@deepseek-ai/dsh-tool-tasks": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..1657f0faa2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -880,6 +880,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subagentControl', + summary: 'The continuable-subagent orchestration service.', + methods: [ + { + signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', + jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', + }, + { + signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the content to deliver.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', @@ -900,6 +914,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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 */', }, + { + signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', + jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The caller (the control service) has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', + }, ], }, { @@ -1783,6 +1801,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContinuableStart', + declaration: 'export interface ContinuableStart {\n readonly childId: SessionId;\n readonly taskId: TaskId;\n}', + }, + { + name: 'ContinuableStartSpec', + declaration: 'export interface ContinuableStartSpec {\n readonly provider: string;\n readonly label: string;\n readonly request: Omit;\n}', + }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -2331,6 +2357,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, + { + name: 'SendMessageResult', + declaration: 'export type SendMessageResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', + }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2651,21 +2681,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ 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: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n resume?(request: SubagentResumeRequest): Promise;\n}', }, { name: 'SubagentResult', declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, + { + name: 'SubagentResumeRequest', + declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n steer?(content: ContentBlock[]): void;\n}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n readonly continuation?: SubagentContinuation;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ab8bc2730..ed074df4a6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index a875878075..0491b589c6 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: fed0c3d6b252f5eeb8355c3b544066765999120a -README.zh.md: 9bc187aa972bc92385d32fe787e2fd65b6ce8361 +README.md: 438907ea7de41842f900b050385f15feac7cc272 +README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec diff --git a/packages/subagent/README.md b/packages/subagent/README.md index fed0c3d6b2..438907ea7d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,14 +6,16 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `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 | (registers on `ctx.subagents`) | -| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | +| `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`) | +| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | +| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | +| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. 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 lives at `subagent/subagent/`. 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). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures. -The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). +The proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 9bc187aa97..87911216bc 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,14 +6,16 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | 抽象 subagent seam:具名提供方注册表与词汇 | `ctx.subagents` | +| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇与持久化子 agent 描述符 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | -| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | -| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | +| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | +| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | +| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | +| `subagent-control/` | 可继续子 agent 编排:稳定 ID、描述符查找、由 Task 支撑的 activation,以及 steer 或恢复路由 | `ctx.subagentControl` | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | +| `tool-subagent-control/` | 基于 `ctx.subagentControl`、全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation,模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 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-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)。 diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md new file mode 100644 index 0000000000..002613d508 --- /dev/null +++ b/packages/subagent/subagent-control/README.md @@ -0,0 +1,37 @@ +# @deepseek-ai/dsh-subagent-control + +The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. + +## Activation lifecycle + +A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. + +`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. + +Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). + +The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications. + +## Model Experience + +### Task completion and output + +#### What the model sees + +None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces. + +#### Token effect + +None beyond the consuming tools' own results. + +#### KV Cache effect + +None; this service appends nothing to any model-visible sequence. + +## Known Limitations and Deferred Work + +- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered. +- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet. +- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern. +- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle. +- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved. diff --git a/packages/subagent/subagent-control/package.json b/packages/subagent/subagent-control/package.json new file mode 100644 index 0000000000..9522bca29e --- /dev/null +++ b/packages/subagent/subagent-control/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-subagent-control", + "description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts new file mode 100644 index 0000000000..fa1974eb22 --- /dev/null +++ b/packages/subagent/subagent-control/src/index.ts @@ -0,0 +1,442 @@ +/** + * Continuable-subagent control service (`ctx.subagentControl`): stable child + * ids, descriptor persistence and lookup by known child id, Task-backed + * activation, and steer-or-resume message routing. The low-level + * `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic; + * this service owns the policy that binds one durable child session to a + * series of disposable Task-backed activations. + * + * 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. + * + * @module @deepseek-ai/dsh-subagent-control + */ + +import { randomUUID } from 'node:crypto' +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } 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 '@deepseek-ai/dsh-subagent' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' + +declare module 'cordis' { + interface Context { + subagentControl: SubagentControlService + } +} + +/** Typed error for control-service routing, authorization, and delivery failures. */ +export class SubagentControlError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentControlError' + } +} + +/** What a caller asks for when starting a continuable background child. */ +export interface ContinuableStartSpec { + /** The `ctx.subagents` provider to establish the child on. */ + 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. + */ + readonly request: Omit +} + +/** Identities returned by {@link SubagentControlService.startContinuable}. */ +export interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The initial activation's Task id. */ + readonly taskId: TaskId +} + +/** + * How {@link SubagentControlService.sendMessage} delivered a message: + * `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 message. Failure is an exception, never a result — + * an undelivered message throws. + */ +export type SendMessageResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } + +/** + * 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. + */ +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 + /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ + readonly terminal: PromiseWithResolvers +} + +/** + * 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. + */ +export 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) } + } +} + +/** + * 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: String(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 sendMessage} and share one activation result and cancellation + * boundary, while foreground one-shot delegation keeps calling + * `ctx.subagents.start()` directly. + */ +export class SubagentControlService extends Service { + static inject = ['subagents', 'tasks', 'agents'] + + /** Child session id → its current activation. Process-local, never durable. */ + private activations = new Map() + + constructor(ctx: Context) { + super(ctx, 'subagentControl') + // 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() + } + }) + ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()') + } + + /** + * 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. + */ + startContinuable(spec: ContinuableStartSpec): ContinuableStart { + 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 descriptor = snapshotSubagentDescriptor({ + provider: spec.provider, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...request.persona !== undefined ? { persona: request.persona } : {}, + ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, + }) + const taskId = this.startActivation(childId, spec.label, request.parent, signal => + this.ctx.subagents.start(spec.provider, { + ...request, + signal, + continuation: { sessionId: childId, descriptor }, + })) + return { childId, taskId } + } + + /** + * Deliver one message to 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. A throw 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. + * @param message - the content to deliver. + * @returns whether the message `steered` the existing Task or `started` a new one. + */ + sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult { + this.assertOwnership(childId) + const activation = this.activations.get(childId) + if (activation !== undefined) { + return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) } + } + return { route: 'started', taskId: this.resumeActivation(parent, childId, message) } + } + + /** + * 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. + */ + 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) { + throw new SubagentControlError( + `subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`, + 'OWNERSHIP_CONFLICT', + ) + } + if (activation.run !== undefined && activation.run.localAgent !== live) { + throw new SubagentControlError( + `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 strict live steering. */ + private steerActivation( + activation: ActiveActivation, + parent: Agent, + childId: SessionId, + message: ContentBlock[], + ): TaskId { + 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 SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + // Owner-session authorization plus the live status for the strict check. + const snapshot = this.ctx.tasks.get(taskId, parent) + if (snapshot.status !== 'running') { + throw new SubagentControlError( + `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 SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + if (run.steer === undefined) { + throw new SubagentControlError( + `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, + 'NOT_DELIVERED', + ) + } + try { + run.steer(message) + } catch (error: unknown) { + // Strict steering lost the race with turn settlement. Deliberately no + // cold-resume fallback here: that would attach the message to a turn the + // caller did not observe. + throw new SubagentControlError( + `subagent "${childId}" stopped before delivery; the message was not delivered`, + 'NOT_DELIVERED', + { cause: error }, + ) + } + return taskId + } + + /** + * 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. + */ + private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): 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 SubagentControlError( + `subagent "${childId}" is unavailable`, + 'NOT_RESUMABLE', + { cause: error }, + ) + } + // The persistence seam takes no signal; recheck before any child work. + if (signal.aborted) throw new SubagentControlError('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 SubagentControlError( + `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 SubagentControlError( + `subagent "${childId}" has no supported continuation descriptor`, + 'NOT_RESUMABLE', + ) + } + return this.ctx.subagents.resume(descriptor.provider, { + sessionId: childId, + prompt: message, + parent, + signal, + descriptor, + }) + }) + } + + /** + * 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. + */ + private startActivation( + childId: SessionId, + label: string, + owner: Agent, + begin: (signal: AbortSignal) => Promise, + ): TaskId { + const activation: ActiveActivation = { + taskId: undefined, + run: 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 controller = new AbortController() + const done = (async (): Promise => { + try { + const run = await begin(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 controller.signal.aborted + ? { status: 'killed' } + : { status: 'failed', detail: String(error) } + } + })() + 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. + controller.abort(reason ?? 'subagent activation killed') + }, + done, + // No readOutput: the child session owns intermediate detail. + } + }, + }) + } catch (error: unknown) { + // Task preflight failed; nothing started, so the install rolls back. + this.activations.delete(childId) + throw 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. */ + private requirePersistence(): SessionPersistence { + const persistence = this.ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new SubagentControlError( + 'continuable subagents require session persistence (load a dsh-session-persistence backend)', + 'PERSISTENCE_UNAVAILABLE', + ) + } + return persistence + } +} + +/** 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 default SubagentControlService diff --git a/packages/subagent/subagent-control/src/invariant.ts b/packages/subagent/subagent-control/src/invariant.ts new file mode 100644 index 0000000000..ce40f360ca --- /dev/null +++ b/packages/subagent/subagent-control/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`. + * @module @deepseek-ai/dsh-subagent-control/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control' + +/** Cordis companion plugin name. */ +export const name = 'subagent-control-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the activation association is deliberately private + * process-local routing state with no event stream of its own; the run + * lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`, + * and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts new file mode 100644 index 0000000000..c76cc5ad41 --- /dev/null +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -0,0 +1,539 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +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, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** One scripted response that may wait on a caller-released gate before streaming. */ +interface GatedEntry { + chunks: StreamChunk[] + gate?: Promise +} + +/** Adapter whose entries can hold a model call open until the test releases it. */ +class GatedAdapter extends LlmAdapter { + constructor(private script: GatedEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + const entry = this.script.shift() + if (!entry) throw new Error('GatedAdapter: script exhausted') + if (entry.gate) await entry.gate + for (const chunk of entry.chunks) { + if (options.signal?.aborted) throw new Error('aborted') + yield chunk + } + } +} + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */ +async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + if (options.persistence !== false) { + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks, {}) + await ctx.plugin(SubagentControlService) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +async function setup(script: Script, options: { persistence?: boolean } = {}) { + const adapter = new MockAdapter(script) + const { ctx, parent } = await setupWith(adapter, options) + return { ctx, parent, adapter } +} + +function startSpec(parent: Agent, provider = 'spawn') { + return { + provider, + label: 'delegated work', + request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + } +} + +async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { + return ctx.tasks.wait(taskId, 5_000, parent) +} + +function message(text: string) { + return [{ type: 'text' as const, text }] +} + +describe('SubagentControlService.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.subagentControl.startContinuable(startSpec(parent)) + expect(started.childId).toMatch(/[0-9a-f-]{36}/) + expect(started.taskId).toBe('subagent-1') + + 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() + }) + + it('publishes the control-allocated child id and appends the turn-enclosed 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.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + 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') + expect(descriptorIndex).toBeGreaterThan(turnStartIndex) + expect(descriptorIndex).toBeLessThan(firstAssistant) + const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> + expect(descriptor.data).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'mock', + agentModel: 'mock', + }) + // Model-hidden: the descriptor never carries surface metadata. + expect('surfaceOp' in descriptor).toBe(false) + + // The durable log kept the exact control-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('rejects synchronously with no Task when persistence is not configured', async () => { + const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) + expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + .toThrow(/require session persistence/) + expect(ctx.tasks.list(parent)).toEqual([]) + }) + + 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.subagentControl.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.subagentControl.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 = ctx.subagentControl.sendMessage(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.subagentControl.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() + }) +}) + +describe('SubagentControlService.sendMessage', () => { + 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 }) + const { ctx, parent } = await setupWith(new GatedAdapter([ + { chunks: textResponse('first step answer'), gate }, + { chunks: textResponse('steered turn answer') }, + ])) + + const started = ctx.subagentControl.startContinuable(startSpec(parent)) + // Wait for the child agent to publish and enter running. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (ctx.agents.get(started.childId)?.status === 'running') { + clearInterval(timer) + resolve() + } + }, 5) + }) + + const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y')) + expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) + releaseFirst() + 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') + }) + + 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.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + expect(ctx.agents.get(started.childId)).toBeUndefined() + + const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?')) + 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?']) + }) + + 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.subagentControl.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 = ctx.subagentControl.sendMessage(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.subagentControl.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 = ctx.subagentControl.sendMessage(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.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = ctx.subagentControl.sendMessage(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.subagentControl.startContinuable(startSpec(otherParent)) + await waitTerminal(ctx, started.taskId, otherParent) + + const attempt = ctx.subagentControl.sendMessage(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 = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) + const snapshot = await waitTerminal(ctx, attempt.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('continuation descriptor') + }) + + it('rejects delivery to a live agent outside control-service ownership', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + // A live child created around the control service. + const handle = await ctx.agents.create({ + sessionId: SessionId('rogue-child'), + meta: { parentSession: parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + .toThrow(SubagentControlError) + expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + .toThrow(/outside control-service ownership.*not delivered/) + await handle.dispose() + }) + + it('does not fall through to cold resume when strict steering loses the settlement 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 realStart = ctx.subagents.start.bind(ctx.subagents) + ctx.subagents.start = async (name, request) => { + const run = await realStart(name, 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.subagentControl.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) + }) + + // Strict steering finds the settled child, fails loud, and does NOT start + // a cold resume within this call. + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?'))) + .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 = ctx.subagentControl.sendMessage(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.subagentControl.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = ctx.subagentControl.sendMessage(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.subagentControl.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 = ctx.subagentControl.sendMessage(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.subagentControl.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 = ctx.subagentControl.sendMessage(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. + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up'))) + .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('outcome mapping helpers', () => { + it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { + const output = [{ type: 'text' as const, text: 'partial' }] + expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) + expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) + expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) + expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) + expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) + // Merge-extensible: an unknown reason is failed-with-detail, never success. + expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) + }) + + 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 disposeFailed = await settleRun({ + id: SessionId('child-3'), + 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-4'), + 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', + }) + }) +}) diff --git a/packages/subagent/subagent-control/tsconfig.json b/packages/subagent/subagent-control/tsconfig.json new file mode 100644 index 0000000000..d41aacf4fb --- /dev/null +++ b/packages/subagent/subagent-control/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../subagent" + }, + { + "path": "../../tasks/tasks" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index b448dc309b..55475aee78 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -57,5 +57,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index a96ce4f06e..37e2556d44 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' // `tools` is deliberately NOT injected — same rationale as subagent-spawn: the @@ -67,6 +67,13 @@ class ForkProvider implements SubagentProvider { ...seed.length > 0 ? { seed } : {}, }) } + + resume(request: SubagentResumeRequest) { + // 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) + } } export function apply(ctx: Context, config: Config): void { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index b834818199..bd951115d2 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: 980bc18de088c41dfe2f57a5ff0882a60892fc9f -README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae +README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee +README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 980bc18de0..7587b6dfc4 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, optional child customization, result reading, cancellation, and disposal—has one implementation here. +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, strict steering, and disposal—has one implementation here. ## Start contract @@ -11,21 +11,27 @@ 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. -3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. +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 one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records. +5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. 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, abort handoff, and disposal follow the same contract as 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 the strict `steer` capability: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. + ## 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. @@ -110,5 +116,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 1ceb628371..751e745c6c 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 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。 +本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 ## 启动契约 @@ -11,21 +11,27 @@ 驱动器按以下顺序运行: 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 -2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 +2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 +5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 子 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` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 + ## Spawn 与 fork 输入 `InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 @@ -110,5 +116,4 @@ When you have your final answer, you MUST report it by calling the `structured_o ## 已知限制与延期工作 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8d659ae73b..695283a027 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' -import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import type { + SubagentDescriptorData, + SubagentResult, + SubagentResumeRequest, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — the driver consumes both // opportunistically (the documented `ctx.get` pattern), never as a hard dep. @@ -65,10 +72,27 @@ 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. `agent/step` is the first serial seam + * inside the child's initial turn, so the append lands after `turn/start` and + * before the first request, and reaches persistence with that turn's flush. + */ +function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { + let appended = false + childCtx.on('agent/step', (agent) => { + if (appended) return + appended = true + agent.session.append('subagent/descriptor', descriptor) + }) +} + /** * Establish and drive one in-process 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 inside the child's initial turn. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. @@ -88,7 +112,9 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = SessionId(randomUUID()) + // 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 @@ -123,9 +149,11 @@ export async function startInProcessRun( if (request.outputSchema !== undefined) { structured = attachStructuredRuntime(childCtx, request.outputSchema) } + if (request.continuation !== undefined) { + attachDescriptorAppend(childCtx, request.continuation.descriptor) + } } - const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ sessionId: childId, meta: { @@ -140,36 +168,84 @@ export async function startInProcessRun( signal: request.signal, setup, }) + return driveTurn(handle, request.signal, request.prompt, childId, seedLength, 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 low-level service. + * @returns a fresh ready holder-owned run for this activation. + */ +export async function resumeInProcessRun(request: SubagentResumeRequest): 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) +} + +/** + * 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`, strict steering, and disposal. + */ +function driveTurn( + handle: AgentHandle, + signal: AbortSignal, + prompt: ContentBlock[], + childId: SessionId, + boundary: number, + structured?: StructuredAttachment, +): SubagentRun | Promise { const child = handle.agent // Agent creation detaches its creation-only abort listener before returning. // Close the narrow handoff race before installing the live-run listener. - // Static analysis does not model the abort that may land between the - // factory's listener detachment and this continuation. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (request.signal.aborted) { - flags.cancelled = true - await handle.dispose() - throw prePublicationAbort() + if (signal.aborted) { + return handle.dispose().then(() => { throw prePublicationAbort() }) } + const flags = { cancelled: false } const onAbort = (): void => { flags.cancelled = true child.cancel({ kind: 'parent' }) } - request.signal.addEventListener('abort', onAbort, { once: true }) + signal.addEventListener('abort', onAbort, { once: true }) const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() return readResult( child, - seedLength, + boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, ) } finally { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) } })() @@ -178,21 +254,31 @@ export async function startInProcessRun( localAgent: child, result, dispose(): Promise { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) flags.cancelled = true return handle.dispose() }, + steer(content: ContentBlock[]): void { + // Strict live delivery: the synchronous running check and Agent.steer() + // call share one frame, so delivery joins the observed turn or throws. + // Agent.steer()'s own idle fallback would instead QUEUE the message and + // start a new, untracked turn after this run's result was read. + if (child.status !== 'running') { + throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) + } + child.steer(createUserMessage({ content, source: { kind: 'user' } })) + }, } } -/** Read one settled child's result from events after its optional fork seed. */ +/** Read one settled child's result from events after its activation boundary. */ function readResult( child: Agent, - seedLength: number, + boundary: number, cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { - const own = child.session.events.slice(seedLength) + const own = child.session.events.slice(boundary) const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') const lastEnd = findLastMessageTurnEnd(own) const output: ContentBlock[] = lastMessage?.data.message.content ?? [] diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 868f829edb..811f19e6e6 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -52,5 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. - **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index c006272cd5..22594fef2e 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -8,8 +8,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' +import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { resumeInProcessRun, 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, @@ -46,6 +46,12 @@ class SpawnProvider implements SubagentProvider { // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } + + resume(request: SubagentResumeRequest) { + // 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) + } } export function apply(ctx: Context, config: Config): void { diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 2d6338701d..1e43b074b4 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,12 +235,19 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => { + it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - expect('sendMessage' in run).toBe(false) + // A run represents one disposable activation: cold resume is a provider + // method, never a run method. expect('resume' in run).toBe(false) + expect(typeof run.steer).toBe('function') await run.result + // Strict 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). + expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + .toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3d5d5e7498..c7bf9af45a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -10,17 +10,19 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| -| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. | -| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. | -| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. | -| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. | -| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. | +| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. | +| `@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-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. | +| `@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. | Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract. ## Service API -`SubagentService` has four main operations: +`SubagentService` has five main operations: | Member | Meaning | |---|---| @@ -28,8 +30,9 @@ Multiple providers may coexist under different names. This lets a deployment exp | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate requested capabilities and semantic values, 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. | +| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. | -`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. +`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, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. 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. @@ -42,23 +45,27 @@ Start-time features are advertised in `provider.capabilities` because the servic - `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?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `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. + +## 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()` recovers it from a loaded child log. 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. + ## Delegation depth The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. -Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check. - `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 -`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.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. `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. 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. A continuable start publishes exactly the control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after `start()` 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. +The service emits `subagent/start` only after `start()` or `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. 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. @@ -66,17 +73,17 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then 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 [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. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then 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 [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 -Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only. +Indirectly, through `dsh-tool-subagent` and `dsh-tool-subagent-control`, which render provider-specific schemas and foreground, background, or follow-up results while child working context remains child-only. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool. +- **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. diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts new file mode 100644 index 0000000000..c837a696b8 --- /dev/null +++ b/packages/subagent/subagent/src/descriptor.ts @@ -0,0 +1,116 @@ +/** + * The durable continuable-child descriptor: the versioned, model-hidden + * `subagent/descriptor` session event that records a child's declared + * composition so a known child id can be cold-resumed after its run — and its + * process — are gone. Providers append it turn-enclosed in the child's initial + * turn; the control service folds it back on resume. + * + * The descriptor deliberately snapshots explicit fields rather than the + * merge-extensible `AgentOptions` object: an unrelated extension value cannot + * make continuation fail merely because it is not JSON, and later composition + * inputs require a deliberate {@link SUBAGENT_DESCRIPTOR_VERSION} change. It + * omits `subagentDepth` — cold resume trusts the persisted header's + * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs + * to one activation's result contract rather than durable child composition. + * + * @module @deepseek-ai/dsh-subagent/descriptor + */ + +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Durable declared composition of a continuable subagent child, appended + * once by the establishing provider inside the child's initial turn, + * before its first request. Log-only: it carries no `surfaceOp`, never + * enters model history, and the append-only log retains it when + * compaction replaces surface history. + */ + 'subagent/descriptor': SubagentDescriptorData + } +} + +/** + * The current descriptor format version, stamped into every appended + * `subagent/descriptor` event and required verbatim by {@link foldSubagentDescriptor}. + * Supporting another composition input is a deliberate version change, never + * an implicit extra field. + */ +export const SUBAGENT_DESCRIPTOR_VERSION = 1 + +/** The `subagent/descriptor` event payload — a continuable child's declared composition. */ +export interface SubagentDescriptorData { + /** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */ + readonly version: number + /** The `ctx.subagents` provider name that established the child. */ + readonly provider: string + /** Resolved child `agentOptions.provider`, when one was declared. */ + readonly agentProvider?: string + /** Resolved child `agentOptions.model`, when one was declared. */ + readonly agentModel?: string + /** Per-child persona that shadows the deployment persona on resume. */ + readonly persona?: string + /** Child tool scoping reapplied on resume. */ + readonly toolFilter?: ToolRestriction +} + +/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */ +export interface SubagentDescriptorInput { + /** The `ctx.subagents` provider name that will establish the child. */ + readonly provider: string + /** Requested child `agentOptions.provider`. */ + readonly agentProvider?: string + /** Requested child `agentOptions.model`. */ + readonly agentModel?: string + /** Requested per-child persona. */ + readonly persona?: string + /** Requested child tool scoping. */ + readonly toolFilter?: ToolRestriction +} + +/** + * Validate and detach descriptor inputs into the durable payload, before any + * Task or provider work begins — the same detached lossless-JSON boundary the + * session log itself enforces, applied early so a synchronous validation + * failure rejects the tool call without creating a Task. + * @param input - the caller-collected composition fields. + * @returns the versioned, detached descriptor payload. + * @throws when a field is not losslessly JSON-serializable. + */ +export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData { + const candidate: SubagentDescriptorData = { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: input.provider, + ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, + ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, + ...input.persona !== undefined ? { persona: input.persona } : {}, + ...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {}, + } + const snapshot = snapshotJsonValue(candidate) + if (snapshot === undefined) { + throw new Error('subagent descriptor is not losslessly JSON-serializable') + } + return snapshot +} + +/** + * Fold a persisted child log to its supported descriptor. The first + * `subagent/descriptor` event is authoritative — the establishing provider + * appends exactly one, so a later same-type event cannot rewrite the declared + * composition. + * @param events - the loaded child session events. + * @returns the descriptor, or `undefined` when the log has none or its + * version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not + * resumable by this runtime). + */ +export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined { + const event = events.find( + (candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor', + ) + if (event === undefined) return undefined + if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined + return event.data +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 1267f276ab..4f9a013084 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,12 +13,13 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Scope: the seam stays collection-agnostic — a run is started and its - * `result` awaited, whether the consumer blocks on it (foreground) or - * registers it as a `ctx.tasks` background task (the generic runtime owns - * ids/polling/stop; this seam gains nothing task-shaped). Steering - * ({@link SubagentRun.sendMessage}) is part of the contract but intentionally - * unused. + * Scope: the seam stays collection-, Task-, and persistence-agnostic — a run + * is started or resumed and its `result` awaited, whether the consumer blocks + * on it (foreground) or registers it as a `ctx.tasks` background task (the + * generic runtime owns ids/polling/stop; this seam gains nothing task-shaped). + * Durable continuable-child ids, descriptor lookup, and Task association + * belong to `@deepseek-ai/dsh-subagent-control`; this service only validates + * and dispatches `start`/`resume` and observes run lifecycle. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -41,6 +42,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, + SubagentResumeRequest, SubagentRun, SubagentStartRequest, } from './types.ts' @@ -50,13 +52,21 @@ export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, + SubagentContinuation, SubagentProvider, SubagentResult, + SubagentResumeRequest, SubagentRun, SubagentStartRequest, SubagentStopReason, SubagentStopReasonMap, } from './types.ts' +export { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, +} from './descriptor.ts' +export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -237,16 +247,52 @@ export class SubagentService extends Service { * @returns the ready holder-owned run. */ 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)) + } + + /** + * Resume a persisted continuable child through the named provider's + * `resume` capability, with the same run lifecycle observation as + * {@link start}. The caller (the control service) has already loaded the + * child, folded its descriptor, and authorized the parent; this method owns + * only capability-checked dispatch. + * @param name - the provider recorded in the child's descriptor. + * @param request - the fully resolved resume request. + * @returns the fresh holder-owned run for the resumed activation. + */ + async resume(name: string, request: SubagentResumeRequest): Promise { + const provider = this.expectProvider(name) + if (provider.resume === undefined) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support resuming persisted children (no resume capability)`, + 'UNSUPPORTED_CAPABILITY', + ) + } + return this.observeRun(name, request.parent, await provider.resume(request)) + } + + /** Look up a provider for dispatch or fail loud. */ + private expectProvider(name: string): SubagentProvider { const provider = this.providers.get(name) if (provider === undefined) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } - this.assertCapabilities(provider, request) - assertSubagentMaxDepth(request.maxDepth) - if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) + return provider + } - const parent = request.parent - const run = await provider.start(request) + /** 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, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 527d93c8e1..1537b50aa8 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -9,6 +9,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { 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,9 +28,10 @@ export function SubagentRunId(id: string): SubagentRunId { * {@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. */ export interface SubagentCapabilities { readonly outputSchema: boolean @@ -91,6 +93,56 @@ export 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 +} + +/** + * The resolved continuable-child identity and durable composition record a + * control-service caller attaches to a start request. + */ +export 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 +} + +/** + * 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`. + */ +export 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 } /** @@ -165,15 +217,16 @@ export interface SubagentRun { */ dispose(): Promise /** - * 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 + steer?(content: ContentBlock[]): void } /** @@ -201,4 +254,15 @@ export interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ 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 `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 } diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md new file mode 100644 index 0000000000..c4d27e3694 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-tool-subagent-control + +The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. + +The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. + +## Model Experience + +### Tool schema + +#### 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. + +#### Token effect + +Fixed schema cost per parent request. + +#### KV Cache effect + +Prefix-stable; the schema does not change at runtime. + +### Delivery result + +#### What the model sees + +`message delivered to running task ` when the message joined the running activation, or `message started task continuing subagent ` when it cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability). + +#### Token effect + +One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## 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. diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json new file mode 100644 index 0000000000..96c91c4ec6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-tool-subagent-control", + "description": "Globally named send_message tool over the continuable-subagent control service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent-control": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "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 new file mode 100644 index 0000000000..3c537f471c --- /dev/null +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -0,0 +1,74 @@ +/** + * The globally named `send_message` tool: a thin model-facing adapter over + * `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its + * own — steer-or-resume orchestration belongs to the control 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 + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-subagent-control' + +export const name = 'tool-subagent-control' +export const inject = ['tools', 'subagentControl'] + +/** + * Register the `send_message` tool. + * @param ctx - context carrying the tool registry and the control service. + */ +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.', + parameters: { + subagent_id: { + type: 'string', + required: true, + description: 'The subagent id returned when the background subagent was started.', + }, + message: { + type: 'string', + required: true, + description: 'The message to deliver to the subagent.', + }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + route: { + type: 'string', + required: true, + enum: ['steered', 'started'], + }, + taskId: { type: 'string', required: true }, + }, + }, + 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}`, + }], + }, + execute(args, exec) { + const parent = exec.agent + if (!parent) { + // Non-agent callers have no session to authorize Task access with. + throw new Error('send_message requires a calling agent (exec.agent was undefined)') + } + const message: ContentBlock[] = [{ type: 'text', text: args.message }] + const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message) + return Promise.resolve(result) + }, + })) +} diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts new file mode 100644 index 0000000000..6fb1c19ea6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-control`. + * @module @deepseek-ai/dsh-tool-subagent-control/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control' + +/** Cordis companion plugin name. */ +export const name = 'tool-subagent-control-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery + * and activation relations are owned by the control service it calls. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ 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 new file mode 100644 index 0000000000..b54eb6508a --- /dev/null +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +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' + +const testToolSignal = new AbortController().signal + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function setup(script: ConstructorParameters[0]) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + 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(SubagentControlService) + await ctx.plugin(tool) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +let calls = 0 +function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++calls}`), + name, + arguments: args, + ...agent !== undefined ? { agent: agent as never } : {}, + }) +} + +describe('dsh-tool-subagent-control', () => { + it('registers send_message once, globally, with the two required parameters', async () => { + const { ctx } = await setup([]) + const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message') + 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') + }) + + it('cold-resumes a settled child and renders the started route with its task id', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const started = ctx.subagentControl.startContinuable({ + provider: 'spawn', + label: 'work', + request: { prompt: [{ type: 'text', text: 'child task' }], parent }, + }) + await ctx.tasks.wait(started.taskId, 5_000, parent) + + 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]') + }) + + 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 + // Reach past the tool into the control service to fake a running route + // deterministically: the tool is a thin adapter, so its steered wording is + // what this test pins. + ctx.subagentControl.sendMessage = (agent, _childId, message) => { + steered = (message[0] as { text: string }).text + return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } + } + const result = await callTool(ctx, 'send_message', { + subagent_id: 'some-child', + message: 'also consider Y', + }, parent) + expect(result.isError).toBe(false) + expect(steered).toBe('also consider Y') + expect(text(result)).toBe('message delivered to running task subagent-9') + }) + + it('reports a control-service failure as an errored, not-delivered result', async () => { + const { ctx, parent } = await setup([]) + const result = await callTool(ctx, 'send_message', { + 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') + } + }) + + it('fails loud when invoked without a calling agent', async () => { + const { ctx } = await setup([]) + const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('requires a calling agent') + }) + + it('unregisters with its plugin fiber (HMR safety)', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(SubagentControlService) + const fiber = await ctx.plugin(tool) + expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) + await fiber.dispose() + expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in tool).toBe(false) + expect(tool.name).toBe('tool-subagent-control') + expect(tool.inject).toEqual(['tools', 'subagentControl']) + expect(typeof tool.apply).toBe('function') + }) +}) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json new file mode 100644 index 0000000000..4b2ec045e6 --- /dev/null +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../subagent-control" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 9c8e235669..c4660b5517 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: 20bb6b9c59a13f23301368faefe18849ccc0b1e9 -README.zh.md: 8da57896359f5ac47d0ec076c3395d2e7fb1e02a +README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1 +README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 20bb6b9c59..7d32da3c97 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`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; 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). +With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent as task `, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task `. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; 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) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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). @@ -64,7 +64,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. +Start returns exactly `started subagent as task ` on a resumable provider, or `started background subagent task ` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8da5789635..eadc168fd0 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` 后,工具会在启动提供方前注册父级拥有的任务,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `。任务拥有的信号覆盖待处理的启动阶段,以及启动调用返回后的子 agent。`task_kill` 和所有者 dispose(资源释放)会中止它。结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)。 +设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent as task `,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task `。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent 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)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -64,7 +64,7 @@ #### 模型看到的内容 -启动时原样返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知。 +对于可恢复提供方,启动时精确返回 `started subagent as task `;对于一次性提供方,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index b5c7b5d94f..d789c9b4f9 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-control": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -43,7 +44,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index a89abaa139..1b8f2e4cbf 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,20 +1,23 @@ /** * Model-facing delegation through one configured `ctx.subagents` provider. * Provider lifecycle controls tool registration and context-sensitive schema - * wording. Foreground calls always dispose the run after collection; background - * calls use an independent cancellation signal and settle a final-output task - * only after child disposal. + * wording. Foreground calls always dispose the run after collection. A + * background call's route follows the provider's continuation capability: + * a provider with `resume` delegates to `ctx.subagentControl`, which owns the + * durable child id, its descriptor, and the Task-backed activation lifecycle; + * a provider without it (ACP) keeps the one-shot background task. * @module @deepseek-ai/dsh-tool-subagent */ import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' -import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' +import { settleRun } from '@deepseek-ai/dsh-subagent-control' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' export const name = 'tool-subagent' @@ -85,18 +88,6 @@ export const Config: z = z.object({ maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3), }) -/** - * Flatten a child's final output blocks to text for the tool result. The child - * may return non-text blocks; this path returns only text. Structured results - * use `outputSchema`. - */ -function outputText(blocks: ContentBlock[]): string { - return blocks - .filter((b): b is Extract => b.type === 'text') - .map(b => b.text) - .join('') -} - /** Render text blocks from the canonical JSON block array without trusting arbitrary values. */ function outputValueText(values: JsonValue[]): string { return values @@ -107,6 +98,17 @@ function outputValueText(values: JsonValue[]): string { .join('') } +/** Settle pending startup without rejecting the task producer contract. */ +async function settleStart(start: Promise, signal: AbortSignal): Promise { + try { + return await settleRun(await start) + } catch (error: unknown) { + return signal.aborted + ? { status: 'killed' } + : { status: 'failed', detail: String(error) } + } +} + /** A non-`completed` stop reason means the child did not finish cleanly. */ function stopReasonError(result: SubagentResult): string | undefined { switch (result.stopReason) { @@ -127,50 +129,6 @@ function stopReasonError(result: SubagentResult): string | undefined { } } -/** - * 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. - */ -export function runOutcome(result: SubagentResult): TaskOutcome { - switch (result.stopReason) { - case 'completed': - return { status: 'completed', output: outputText(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) } - } -} - -/** - * 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: String(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 -} - /** * Model-facing wording from the provider's conversation-history descriptor * ({@link SubagentProvider.inheritsParentContext}). @@ -210,30 +168,6 @@ function providerWording(inheritsConversation: boolean): { description: string; } } -function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest { - const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined - return { - prompt: [{ type: 'text', text: prompt }], - parent, - signal, - ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, - ...maxDepth !== undefined ? { maxDepth } : {}, - } -} - -/** Settle pending startup without rejecting the task producer contract. */ -async function settleStart(start: Promise, signal: AbortSignal): Promise { - try { - return await settleRun(await start) - } catch (error: unknown) { - return signal.aborted - ? { status: 'killed' } - : { status: 'failed', detail: String(error) } - } -} - export function apply(ctx: Context, config: Config): void { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). @@ -257,10 +191,18 @@ export function apply(ctx: Context, config: Config): void { } const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false + // The provider's continuation capability decides the background route: a + // resumable provider starts durable, follow-up-able children through the + // control service, while a one-shot provider (ACP) keeps the plain task. + const continuable = provider.resume !== undefined disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled - ? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' + ? continuable + ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' + + ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,' + + ' and send follow-up messages with `send_message`.' + : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { description: { @@ -276,7 +218,10 @@ export function apply(ctx: Context, config: Config): void { ...backgroundEnabled ? { run_in_background: { type: 'boolean' as const, - description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.', + description: continuable + ? 'Run as a continuable background subagent and return its subagent and task ids; ' + + 'collect with task_output, stop with task_kill, follow up with send_message.' + : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, }, @@ -289,6 +234,7 @@ export function apply(ctx: Context, config: Config): void { properties: { kind: { type: 'string', required: true, const: 'background' }, taskId: { type: 'string', required: true }, + subagentId: { type: 'string' }, }, }, { @@ -305,7 +251,9 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.kind === 'background' - ? `started background subagent task ${value.taskId}` + ? value.subagentId === undefined + ? `started background subagent task ${value.taskId}` + : `started subagent ${value.subagentId} as task ${value.taskId}` : outputValueText(value.output), }], }, @@ -316,27 +264,54 @@ export function apply(ctx: Context, config: Config): void { throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') } + const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined + const request = { + prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], + parent, + ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, + } + if (args.run_in_background === true) { // The validator permits undeclared keys, so schema omission also needs // execution-time enforcement. if (!backgroundEnabled) { throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)') } + if (continuable) { + const control = ctx.get('subagentControl') + if (control === undefined) { + throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') + } + // The control service owns the durable child id, descriptor + // snapshot, Task registration, and settle-then-dispose ordering; a + // synchronous validation failure rejects the call with no Task. + const started = control.startContinuable({ + provider: config.provider, + label: args.description, + request, + }) + return { + kind: 'background' as const, + taskId: started.taskId, + subagentId: started.childId, + } + } const tasks = ctx.get('tasks') if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Task preflight finishes before the starter can spawn a child. + // One-shot background child: task preflight finishes before the + // starter can spawn, and the task-owned signal covers startup. const id = tasks.start({ kind: 'subagent', label: args.description, owner: parent, run: () => { const controller = new AbortController() - const start = ctx.subagents.start( - config.provider, - startRequest(config, args.prompt, parent, controller.signal), - ) + const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal }) return { cancel: (reason?: string) => { controller.abort(reason ?? 'background subagent task killed') @@ -349,14 +324,10 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'background' as const, taskId: id } } - const request = startRequest( - config, - args.prompt, - parent, - exec.signal, - ) - - const run: SubagentRun = await ctx.subagents.start(config.provider, request) + const run: SubagentRun = await ctx.subagents.start(config.provider, { + ...request, + signal: exec.signal, + }) try { const result = await run.result diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 5c27a09e2b..41b383bf9e 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' @@ -6,13 +9,18 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { runOutcome, settleRun } from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal @@ -808,58 +816,75 @@ describe('dsh-tool-subagent background mode', () => { expect(text(killed)).toBe('(no new output)\n[status: killed]') }) - it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { - const output = [{ type: 'text' as const, text: 'partial' }] - expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) - expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) - expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) - expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) - expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) - // Merge-extensible: an unknown reason is failed-with-detail, never success. - expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) +}) + +describe('dsh-tool-subagent continuable background mode', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) - 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']) + /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ + async function continuableSetup() { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + 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(SubagentControlService) + await ctx.plugin(tool, { provider: 'spawn' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([ + textResponse('continuable answer'), + ])) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } + } - // 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) + it('a resumable provider advertises send_message and returns both ids', async () => { + const { ctx, parent } = await continuableSetup() + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).toContain('send_message') - const disposeFailed = await settleRun({ - id: SessionId('child-3'), - 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 started = await callSubagent( + ctx, + { description: 'continuable work', prompt: 'dig in', run_in_background: true }, + { agent: parent }, + ) + expect(started.isError).toBe(false) + const match = /^started subagent (\S+) as task (\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 loaded = await ctx.sessionPersistence.load(SessionId(childId!)) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + }) - const bothFailed = await settleRun({ - id: SessionId('child-4'), - 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', + it('fails loud when the provider is resumable but the control service is not loaded', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + // A resumable provider without ctx.subagentControl. + ctx.subagents.registerProvider({ + name: 'resumable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => { throw new Error('unreachable') }, + resume: () => { throw new Error('unreachable') }, }) + await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') }) }) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 25780c367f..a542b520b1 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../subagent" }, + { + "path": "../subagent-control" + }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a8b7f4a8..534f592f65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:* + version: link:../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -800,6 +803,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:* + version: link:../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:* version: link:../packages/tasks/tool-tasks @@ -4920,6 +4926,51 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/subagent-control: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@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 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -5115,9 +5166,24 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../subagent-control + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5137,6 +5203,57 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/subagent/tool-subagent-control: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../subagent-control + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@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 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/subprocess/subprocess: devDependencies: '@deepseek-ai/dsh-invariants': @@ -6387,6 +6504,9 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:^ version: link:../../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -6447,6 +6567,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../packages/tasks/tool-tasks diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5af9f4fc8c..df5d302915 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 16fa0265d6..61d7894711 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1775, "docs/AGENTS.md": 1150, - "docs/architecture.md": 1920, + "docs/architecture.md": 2040, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 97685a9086..d8c7ee2715 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,7 +160,11 @@ export const LINK_MAP: Readonly> = { SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', + ContinuableStart: 'subagent.md', + ContinuableStartSpec: 'subagent.md', + SendMessageResult: 'subagent.md', SubagentProvider: 'subagent.md', + SubagentResumeRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 77c0302dce..02215862db 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -430,6 +430,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-subagent', 'tool-ralph'], note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', }, + { + key: 'subagentControl', + pkg: 'subagent', + title: 'Continuable-subagent control service', + mode: 'core', + consumers: ['tool-subagent', 'tool-subagent-control'], + note: 'Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages.', + }, { key: 'tasks', pkg: 'tasks', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..9d3907821a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -28,6 +28,8 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import SubagentControlService from '@deepseek-ai/dsh-subagent-control' +import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' @@ -106,6 +108,9 @@ 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')), + // Presence marks the continuation capability, so tool-subagent harvests + // its shipped continuable background wording (spawn/fork are resumable). + resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), } ctx.subagents.registerProvider(provider) } @@ -379,6 +384,22 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', }, + { + pkg: '@deepseek-ai/dsh-tool-subagent-control', + dir: 'tool-subagent-control', + source: 'packages/subagent/tool-subagent-control/src/index.ts', + requires: ['ctx.tools', 'ctx.subagentControl'], + writes: ['tool/call', 'tool/result', 'child session events through the control service'], + async mount(ctx) { + await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SubagentControlService) + await ctx.plugin(ToolSubagentControl) + }, + note: + 'The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once.', + }, { pkg: '@deepseek-ai/dsh-tool-tasks', dir: 'tool-tasks', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index da1375b1d6..7dfd3195a8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,6 +1094,16 @@ "symbol": "SubagentStartRequest", "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": "SubagentResumeRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", diff --git a/tsconfig.host.json b/tsconfig.host.json index ef72d6a43d..3f46bf5ee6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -178,7 +178,9 @@ { "path": "./packages/support/loader-smoke" }, { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, + { "path": "./packages/subagent/subagent-control" }, { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/tool-subagent-control" }, { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, From 3be7ca8664a120cb565891aeb3f6aa0fbdae681e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:14:28 +0800 Subject: [PATCH 04/90] test(loader-smoke): normalize /private/tmp temp paths macOS realpaths temp dirs into /private. The normalizer only stripped the /private prefix for TMPDIR under /var; a TMPDIR under /tmp (any explicitly relocated temp root) left one side canonicalized and the comparison failing. Accept both shapes. --- packages/support/loader-smoke/tests/loader-smoke.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 7a803f2c80..5124147cd8 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -8,7 +8,8 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l const configPath = '/tmp/fixture.cordis.yml' const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url)) -const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '') +// macOS realpaths temp dirs into /private; TMPDIR may live under /var or /tmp. +const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/(?:var|tmp)\/)/, '') describe('runLoaderSmoke', () => { it('isolates the process, closes stdin, captures output, and removes the cwd', async () => { From 71570d7becc259365705868219163c23e14190ff Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 17:53:20 +0800 Subject: [PATCH 05/90] fix: address codex review round 1 - Strict steer now rejects the two windows where an acknowledged message would be silently dropped: the closed-turn durability-flush window (status still running, loop strands drained steering) and a committed structured capture (terminal turn-stop discards late steering). Seam JSDoc, catalog doc, README, and the Agent Note bilingual pair state the tightened contract; new keyless tests pin both rejections. - Continuable background delegation now fails loud when the advertised send_message tool is not registered, instead of starting a durable child the model cannot continue. The acp-agent example already loads the control tool; the tool-catalog boot recipe is unaffected because capability wording is harvested at mount. --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- docs/core-data-structures/subagent.md | 9 ++-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 19 ++++++++- .../tests/structured.spec.ts | 27 ++++++++++++ .../tests/subagent-inprocess.spec.ts | 41 +++++++++++++++++++ packages/subagent/subagent/src/types.ts | 9 ++-- packages/subagent/tool-subagent/src/index.ts | 7 ++++ .../tool-subagent/tests/tool-subagent.spec.ts | 19 ++++++++- 11 files changed, 126 insertions(+), 15 deletions(-) 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 d8bdceb531..3b5eaed10d 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 -2026-07-21-continuable-background-subagents.md: 25ae582b129b2e2dc4a34c6fb3c0247aa644677a -2026-07-21-continuable-background-subagents.zh.md: f7a09ce0519874dad8b32835d0b43914a37350c8 +2026-07-21-continuable-background-subagents.md: abb8a89bd6ec0fbe4a36e7f82c1356fb96b38390 +2026-07-21-continuable-background-subagents.zh.md: 30207dfd757eeada3e8ba961b67c79db3c98aad6 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 25ae582b12..abb8a89bd6 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 @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c 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 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. +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 with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), and no structured capture may have committed (its terminal stop makes the loop discard late steering). 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 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. 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 f7a09ce051..30207dfd75 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 @@ -49,7 +49,7 @@ durable child Session 每个可继续 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 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ec69056602..b5ec558351 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -240,10 +240,11 @@ interface SubagentRun { /** * 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 + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7587b6dfc4..f0660b1a5b 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -30,7 +30,7 @@ The required request signal covers both startup and the live run. Before publica 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 the strict `steer` capability: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 695283a027..176acb4d70 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -259,13 +259,30 @@ function driveTurn( return handle.dispose() }, steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous running check and Agent.steer() + // Strict live delivery: the synchronous checks and the Agent.steer() // call share one frame, so delivery joins the observed turn or throws. // Agent.steer()'s own idle fallback would instead QUEUE the message and // start a new, untracked turn after this run's result was read. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } + // The status stays `running` through the closed turn's durability flush, + // and the loop DISCARDS terminal-stopped steering drained after turn + // close instead of recording it. Requiring an open turn keeps + // acknowledged delivery honest. + const lastBoundary = child.session.events.findLast( + event => event.type === 'turn/start' || event.type === 'turn/end', + ) + if (lastBoundary?.type !== 'turn/start') { + throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) + } + // A committed structured capture makes the pending `agent/turn-stop` + // checkpoint terminal, and the loop then discards late steering. The + // capture is synchronously observable, so reject rather than + // acknowledge a message the run is about to drop. + if (structured?.captured() !== undefined) { + throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) + } child.steer(createUserMessage({ content, source: { kind: 'user' } })) }, } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index ff6db01285..62ef25e095 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,6 +120,33 @@ describe('in-process structured output', () => { await run.dispose() }) + it('strict steer rejects delivery once the structured result is captured', async () => { + // Hold the capture's tool result open so the child is observably running + // with a committed capture: the pending agent/turn-stop checkpoint is + // terminal, and the loop would DISCARD a steering message, so an + // acknowledged delivery here would be a lie. + let releaseResult: (() => void) | undefined + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + ctx.on('agent/post-step', (agent) => { + if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return + return new Promise((resolve) => { releaseResult = resolve }) + }) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseResult !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) }) + .toThrow(/already reported its structured result; the message was not delivered/) + releaseResult!() + const result = await run.result + 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 fb631ee10e..1de7329ecc 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -245,4 +245,45 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + + it('strict steer 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 + // The child is idle after its turn: Agent.steer() would silently QUEUE. + expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + .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('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { + // Hold the turn-end durability flush open: the turn has closed in the log + // and status is still `running`, exactly the window where the loop would + // discard a drained steering message instead of recording it. + const { ctx, parent } = await setup([textResponse('quick')]) + let releaseFlush: (() => void) | undefined + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined || releaseFlush !== undefined) return + const lastEnd = session.events.findLast(event => event.type === 'turn/end') + if (lastEnd === undefined) return + return new Promise((resolve) => { releaseFlush = resolve }) + }) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + // Wait until the child's turn has closed while the flush keeps it running. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseFlush !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(child.status).toBe('running') + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + .toThrow(/turn has already closed; the message was not delivered/) + releaseFlush!() + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1537b50aa8..8cc88eb0c2 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,10 +219,11 @@ export interface SubagentRun { /** * 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 + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 1b8f2e4cbf..51e97f10d1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -285,6 +285,13 @@ export function apply(ctx: Context, config: Config): void { if (control === undefined) { throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') } + // The schema above tells the model to follow up with + // `send_message`; starting a durable child the model cannot + // continue would make that advertisement false. Sibling load order + // is undetermined at mount, so the check lives at the operation. + if (ctx.tools.get('send_message') === undefined) { + throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') + } // The control service owns the durable child id, descriptor // snapshot, Task registration, and settle-then-dispose ordering; a // synchronous validation failure rejects the call with no Task. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 41b383bf9e..9c3ddb461f 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -17,6 +17,7 @@ import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' @@ -825,7 +826,7 @@ describe('dsh-tool-subagent continuable background mode', () => { }) /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ - async function continuableSetup() { + async function continuableSetup(options: { controlTool?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) @@ -837,6 +838,7 @@ describe('dsh-tool-subagent continuable background mode', () => { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) await ctx.plugin(SubagentControlService) + if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl) await ctx.plugin(tool, { provider: 'spawn' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), @@ -886,6 +888,21 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') }) + + it('fails loud when the advertised send_message tool is not registered', async () => { + // The schema tells the model to follow up with send_message; starting a + // durable child the model cannot continue would make that false. + const { ctx, parent } = await continuableSetup({ controlTool: false }) + const result = await callSubagent( + ctx, + { description: 'd', prompt: 'p', run_in_background: true }, + { agent: parent }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') + // Nothing was started: no Task exists for the parent. + expect(ctx.tasks.list(parent)).toEqual([]) + }) }) describe('background preflight failure (no orphaned child, by construction)', () => { From 4eda48d002f3d9cab151aa5de8a2e2c09a41dbf8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 18:14:37 +0800 Subject: [PATCH 06/90] fix: address codex review round 2 - Wire the control service and send_message tool into every shipped composition with a resumable provider and background enabled (headless-agent, tui-agent, and the SDK helper's subagent feature base resources); jsonrpc-agent disables background and is unchanged. - Resolve the send_message availability check in the CALLER's tool scope so a restriction that removes the follow-up tool from one agent also blocks that agent's continuable start. - Control-service disposal now cancels live activations and awaits producer settlement instead of stranding them: TaskService keeps producer Tasks across a reload, so the disposing service aborts each activation-owned controller, resolves its terminal gate (the effect-scoped onTaskDone listener is already gone), and awaits done. A new test kills a mid-start activation through HMR disposal. --- apps/cli/composition.md | 6 +++ apps/cli/config/base.cordis.yml | 9 ++++ apps/cli/package.json | 2 + docs/cordis-catalog/services.md | 2 +- examples/headless-agent/composition.md | 6 +++ examples/headless-agent/cordis.yml | 9 ++++ .../sdk/helper/src/features/builtin/index.ts | 8 +++- .../subagent/subagent-control/src/index.ts | 30 ++++++++++--- .../tests/subagent-control.spec.ts | 45 +++++++++++++++++++ packages/subagent/tool-subagent/src/index.ts | 6 ++- .../tool-subagent/tests/tool-subagent.spec.ts | 16 +++++++ pnpm-lock.yaml | 6 +++ 12 files changed, 136 insertions(+), 9 deletions(-) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index c4deb098c4..76844279f6 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,6 +94,10 @@ flowchart LR cfg --> plugin_tui_subagent_spawn plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_tui_subagent_fork + plugin_tui_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_tui_subagent_control + plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_tui_tool_subagent_control plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_tui_tool_subagent plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -185,6 +189,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index df7ed94258..4982a521a7 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -257,6 +257,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the control tool registers the one global +# `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index 58368f04d9..4cfdf29725 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -101,6 +101,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", @@ -120,6 +121,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0e83259ea2..9028df3814 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1988,7 +1988,7 @@ sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMes 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) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:152`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:156`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 38774195e1..ecf343a264 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,6 +37,10 @@ flowchart LR cfg --> plugin_headless_subagent_spawn plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_headless_subagent_fork + plugin_headless_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] + cfg --> plugin_headless_subagent_control + plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_headless_tool_subagent_control plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_headless_tool_subagent plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -70,6 +74,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 3a74bd4976..73673aee8d 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -86,6 +86,15 @@ config: providerName: fork +# Continuable background children: the control service owns durable child ids +# and Task-backed activations; the control tool registers the one global +# `send_message` shared by both delegation tools. +- id: subagent-control + name: '@deepseek-ai/dsh-subagent-control' + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 09fe8eb5b7..31b4fc77c3 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -209,7 +209,13 @@ config: id: 'subagent', summary: 'Delegate work to child agents', mode: 'multiple', - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }], + // The control pair rides every resumable in-process option: background + // delegation on spawn/fork is continuable and advertises send_message. + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, + { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, + { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, + ], options: [ { id: 'spawn', diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index fa1974eb22..b1aa00ab02 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -86,6 +86,10 @@ interface ActiveActivation { 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 } @@ -164,7 +168,21 @@ export class SubagentControlService extends Service { if (activation.taskId === snapshot.id) activation.terminal.resolve() } }) - ctx.effect(() => () => { this.activations.clear() }, 'subagentControl.activations()') + // TaskService deliberately keeps producer Tasks alive across a + // control-surface or producer reload, so this service'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 control service disposed') + activation.terminal.resolve() + } + await Promise.allSettled(active.map(activation => activation.done ?? Promise.resolve())) + }, 'subagentControl.activations()') } /** @@ -368,6 +386,8 @@ export class SubagentControlService extends Service { const activation: ActiveActivation = { taskId: undefined, run: undefined, + controller: new AbortController(), + done: undefined, terminal: Promise.withResolvers(), } this.activations.set(childId, activation) @@ -378,21 +398,21 @@ export class SubagentControlService extends Service { label, owner, run: (): TaskHooks => { - const controller = new AbortController() const done = (async (): Promise => { try { - const run = await begin(controller.signal) + 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 controller.signal.aborted + 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) @@ -401,7 +421,7 @@ export class SubagentControlService extends Service { cancel: (reason?: string) => { // Cancellation targets the whole activation: every message that // joined this turn shares the `killed` outcome. - controller.abort(reason ?? 'subagent activation killed') + activation.controller.abort(reason ?? 'subagent activation killed') }, done, // No readOutput: the child session owns intermediate detail. diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index c76cc5ad41..391b94a30a 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -482,6 +482,51 @@ describe('SubagentControlService.sendMessage', () => { }) }) +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-control-hmr-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(TaskService) + await ctx.plugin(ToolTasks, {}) + // A provider that stays pending until its signal aborts, so the activation + // is observably mid-start when the control 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')), + }) + const controlFiber = await ctx.plugin(SubagentControlService) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + + const control = ctx.get('subagentControl')! + const started = control.startContinuable({ + provider: 'pending', + label: 'will be interrupted', + request: { prompt: message('go'), parent }, + }) + // TaskService keeps the producer Task; the disposing control service must + // cancel its activation and await settlement rather than strand it. + await controlFiber.dispose() + expect(sawAbort).toBe(true) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + }) +}) + describe('outcome mapping helpers', () => { it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { const output = [{ type: 'text' as const, text: 'partial' }] diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 51e97f10d1..560e0cb20b 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -288,8 +288,10 @@ export function apply(ctx: Context, config: Config): void { // The schema above tells the model to follow up with // `send_message`; starting a durable child the model cannot // continue would make that advertisement false. Sibling load order - // is undetermined at mount, so the check lives at the operation. - if (ctx.tools.get('send_message') === undefined) { + // is undetermined at mount, so the check lives at the operation, + // and it resolves in the CALLER's scope so a restriction that + // removes send_message from this agent also blocks the start. + if (ctx.tools.get('send_message', parent) === undefined) { throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') } // The control service owns the durable child id, descriptor diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9c3ddb461f..f631133970 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -903,6 +903,22 @@ describe('dsh-tool-subagent continuable background mode', () => { // Nothing was started: no Task exists for the parent. expect(ctx.tasks.list(parent)).toEqual([]) }) + + it('resolves send_message availability in the CALLER scope, not the global registry', async () => { + // A scoped restriction that keeps this delegation tool but removes + // send_message means this agent cannot execute the promised follow-up; + // the availability check must see the caller's surface. + const { ctx, parent } = await continuableSetup() + parent.ctx.tools.restrict({ deny: ['send_message'] }) + const result = await callSubagent( + ctx, + { description: 'd', prompt: 'p', run_in_background: true }, + { agent: parent }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') + expect(ctx.tasks.list(parent)).toEqual([]) + }) }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 534f592f65..80d30aeae9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -441,6 +444,9 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control '@deepseek-ai/dsh-tool-tasks': specifier: workspace:^ version: link:../../packages/tasks/tool-tasks From 9e5ae0d12e8582910a81a7e2fc2fb481670d3082 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 23 Jul 2026 18:47:19 +0800 Subject: [PATCH 07/90] fix: address codex review round 3 - Strict steer additionally requires an OPEN STEP: between steps the loop may be awaiting its continuation/turn-stop checkpoints, where pending steering was already folded and a terminal stop discards a later arrival. A message accepted during an open step is drained and recorded at that step's settlement before any terminal decision, so the acknowledged-then-discarded window is closed. New keyless test holds agent/turn-stop open and pins the rejection. - tool-subagent-control README: distinguish synchronous not-delivered errors from started-Task failures (unknown/foreign/descriptor-less ids settle the started Task as failed), and drop the claim that the completion notice carries the child's response. --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 13 +++++++++ .../tests/subagent-inprocess.spec.ts | 29 +++++++++++++++++++ .../subagent/tool-subagent-control/README.md | 4 +-- 7 files changed, 49 insertions(+), 7 deletions(-) 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 3b5eaed10d..6350526088 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 -2026-07-21-continuable-background-subagents.md: abb8a89bd6ec0fbe4a36e7f82c1356fb96b38390 -2026-07-21-continuable-background-subagents.zh.md: 30207dfd757eeada3e8ba961b67c79db3c98aad6 +2026-07-21-continuable-background-subagents.md: a23943a0226d2ef4eee27d7294d7a98a84c5f109 +2026-07-21-continuable-background-subagents.zh.md: e645cfb0a11c554a30a7ad092b612c5bac7d8dea 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 abb8a89bd6..a23943a022 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 @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c 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 strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), and no structured capture may have committed (its terminal stop makes the loop discard late steering). 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. +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 with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), a step must be open (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival), and no structured capture may have committed (its terminal stop makes the loop discard late steering). 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 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. 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 30207dfd75..e645cfb0a1 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 @@ -49,7 +49,7 @@ durable child Session 每个可继续 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()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),必须有打开的 step(step 之间循环可能停在其 continuation/turn-stop 检查点上,此时 steering 已被折叠,终止性 stop 会丢弃之后到达的消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index f0660b1a5b..526b237efa 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -30,7 +30,7 @@ The required request signal covers both startup and the live run. Before publica 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 the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 176acb4d70..43357250cf 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -276,6 +276,19 @@ function driveTurn( if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } + // Terminal turn-stops only run between steps: with no step open, the + // loop may be awaiting its continuation/turn-stop checkpoints, where + // pending steering was already folded and a terminal decision discards + // a later arrival. A message accepted during an OPEN step is instead + // drained and recorded at that step's settlement checkpoint before any + // terminal decision (cancellation remains the documented shared-outcome + // race). + const lastStep = child.session.events.findLast( + event => event.type === 'step/start' || event.type === 'step/end', + ) + if (lastStep?.type !== 'step/start') { + throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) + } // A committed structured capture makes the pending `agent/turn-stop` // checkpoint terminal, and the loop then discards late steering. The // capture is synchronously observable, so reject rather than diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 1de7329ecc..03202a6707 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -258,6 +258,35 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('strict steer rejects the between-steps window where a terminal turn-stop discards steering', async () => { + // Hold `agent/turn-stop` open: the step has closed, pending steering was + // already folded into the continuation decision, and a terminal stop + // would discard a message arriving now — the exact window an + // acknowledged delivery would be a lie. + const { ctx, parent } = await setup([textResponse('quick')]) + let releaseStop: (() => void) | undefined + ctx.on('agent/turn-stop', (agent) => { + if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined + return new Promise((resolve) => { + releaseStop = () => { resolve(undefined) } + }) + }) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseStop !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(child.status).toBe('running') + expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) }) + .toThrow(/between steps; the message was not delivered/) + releaseStop!() + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { // Hold the turn-end durability flush open: the turn has closed in the log // and status is still `running`, exactly the window where the loop would diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index c4d27e3694..7654012145 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -24,11 +24,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 cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability). +`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. #### Token effect -One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice. +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). #### KV Cache effect From bb8ea2be51a57bb4602dbb690c0cb0d29bb3bda8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 11:27:57 +0800 Subject: [PATCH 08/90] fix(subagent): make strict steering atomic --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 2 +- ...-21-continuable-background-subagents.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/cordis-catalog/events.md | 32 ++-- docs/core-data-structures/core.md | 10 ++ docs/event-producer-consumer.md | 32 ++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 18 ++- packages/core/agent/README.md | 1 + packages/core/agent/src/types.ts | 10 ++ .../subagent/subagent-control/src/index.ts | 10 +- .../tests/subagent-control.spec.ts | 129 ++++++++++++++- .../subagent/subagent-inprocess/src/index.ts | 24 +-- .../tests/structured.spec.ts | 31 ++-- .../tests/subagent-inprocess.spec.ts | 152 ++++++++++++++++-- .../subagent/subagent/tests/service.spec.ts | 41 ++++- 24 files changed, 436 insertions(+), 92 deletions(-) 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 6350526088..26572951a6 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 -2026-07-21-continuable-background-subagents.md: a23943a0226d2ef4eee27d7294d7a98a84c5f109 -2026-07-21-continuable-background-subagents.zh.md: e645cfb0a11c554a30a7ad092b612c5bac7d8dea +2026-07-21-continuable-background-subagents.md: 287239a22c440eb4758a8dab5621406246a7e0b7 +2026-07-21-continuable-background-subagents.zh.md: 36b28581e1bf05144e9ffd5de136983eff8fdabc 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 a23943a022..287239a22c 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 @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c 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 strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), a step must be open (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival), and no structured capture may have committed (its terminal stop makes the loop discard late steering). 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. +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 with synchronous checks followed by the default Agent loop's optional atomic `trySteer()`: the child must be `running`, its turn and step must still be open in the log, the step's final steering drain must not have begun, and no structured capture may have committed. The loop closes `trySteer()` acceptance before draining and entering `agent/post-step`, so a terminal stop cannot discard an acknowledged message from that window. A loop without `trySteer()` cannot back strict in-process delivery. 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 operation, `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 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. 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 e645cfb0a1..36b28581e1 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 @@ -49,7 +49,7 @@ durable child Session 每个可继续 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()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),必须有打开的 step(step 之间循环可能停在其 continuation/turn-stop 检查点上,此时 steering 已被折叠,终止性 stop 会丢弃之后到达的消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 先执行同步检查,再调用默认 Agent 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会在排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 49febba2a9..47486ee35f 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: 0e78d7f9157e55ab1c5b6f518ef723e61237446e -architecture.zh.md: 27498c0d36ea54e6c952e0c1264b191d1448a554 +architecture.md: c5788ad33dc87e104dbdf0f420ac937af9ff2662 +architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e diff --git a/docs/architecture.md b/docs/architecture.md index 0e78d7f915..c5788ad33d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +98,7 @@ forever: materialize changed runtime context as sourced 'user/message' snapshot the derived messages (the reconstruction boundary) 'step/start' + open strict-steering acceptance agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -106,7 +107,7 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + close strict-steering acceptance, then drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn otherwise agent/turn-stopping -> drain -> continue only for steering @@ -121,7 +122,7 @@ idle inject: Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. The default loop closes its optional `trySteer()` acceptance immediately before the final steering drain; ordinary `steer()` keeps its best-effort routing semantics. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -135,7 +136,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. +`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, optional `trySteer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. The default loop's `trySteer()` atomically rejects after the current step's final steering drain begins, while ordinary `steer()` retains best-effort routing. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 27498c0d36..db98ead01d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -98,6 +98,7 @@ forever: materialize changed runtime context as sourced 'user/message' snapshot the derived messages (the reconstruction boundary) 'step/start' + open strict-steering acceptance agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -106,7 +107,7 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + close strict-steering acceptance, then drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn otherwise agent/turn-stopping -> drain -> continue only for steering @@ -121,7 +122,7 @@ idle inject: 每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -135,7 +136,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 +`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()`、可选的 `trySteer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。当前步骤开始最后一次排空 steering 后,默认循环的 `trySteer()` 会原子地拒绝调用,而普通 `steer()` 保留尽力路由语义。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index fa8a961f19..f118c5ab44 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 70c63d8a84..91c1f1fabb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -672,6 +672,16 @@ interface Agent { */ steer(message: UserMessage): void + /** + * Atomically submit steering only while the current step still owns its final + * drain. Returns `false` without accepting the message during admission, + * between steps, or after the final per-step drain has begun. Cancellation or + * disposal may still discard previously accepted steering. + * @param message - identified steering content and its producer provenance. + * @returns whether the message entered the current step. + */ + trySteer?(message: UserMessage): boolean + /** * Append model-facing context without running the model — the * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce1e0f1310..0b3e8ff6f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `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:333`](../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:264`](../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:273`](../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:447`](../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:311`](../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:323`](../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:292`](../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:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../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:386`](../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:405`](../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:346`](../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) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:343`](../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:274`](../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:283`](../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:457`](../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:321`](../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:333`](../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:302`](../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:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../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:396`](../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:415`](../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:356`](../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) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 43a87440a2..5c484484bb 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 2a060ef30b..70ddefa5ea 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index b092257bbd..3d58a9067a 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 487541609a..39d437b56c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 4da592774f..076ee192f6 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"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."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1657f0faa2..b090b438e6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8fc9341623..98c0fae6bc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -132,7 +132,6 @@ export class ReactLoopAgent implements Agent { private abort: AbortController | undefined /** Resolves when the current admission and turn exit. */ done: Promise = Promise.resolve() - /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ readonly scope: Scope /** The agent's scoped composition context ({@link Agent.ctx}). */ @@ -143,6 +142,8 @@ export class ReactLoopAgent implements Agent { /** Whether the session log is owed a matching turn end event. */ private turnOpen = false private stepOpen = false + /** Whether {@link trySteer} can still join the current step's final drain. */ + private strictSteeringOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -242,6 +243,16 @@ export class ReactLoopAgent implements Agent { }) } + /** Atomically steer only while the current step still owns its final drain. */ + trySteer(input: UserMessage): boolean { + if (!this.strictSteeringOpen) return false + this.send(input, { + target: 'next-step', + wakeup: true, + }) + return true + } + /** Append model-facing context without waking the driver. */ inject(input: UserMessage): void { this.send(input, { @@ -500,6 +511,7 @@ export class ReactLoopAgent implements Agent { case 'request-failed': { // step() reports request failures only after step/start commits // and before its own step/end, so the step is always open here. + this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) if (!signal.aborted) { @@ -535,6 +547,7 @@ export class ReactLoopAgent implements Agent { } catch (caught: unknown) { try { if (this.stepOpen) { + this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) } @@ -552,6 +565,7 @@ export class ReactLoopAgent implements Agent { // failure paths (step(), the request-failed branch, the catch), so the // finally owes only the turn boundary. this.acceptsNextStep = false + this.strictSteeringOpen = false try { if (this.turnOpen) { // Re-entrant turn/end listeners must route new input to a later turn. @@ -624,6 +638,7 @@ export class ReactLoopAgent implements Agent { session.append('step/start', { turn, step }) this.stepOpen = true + this.strictSteeringOpen = true signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( @@ -692,6 +707,7 @@ export class ReactLoopAgent implements Agent { // Tool results stay adjacent to their calls; input accepted during the // request enters the log only after the complete result batch. + this.strictSteeringOpen = false const steered = this.drainOutbox(turn) session.append('step/end', { turn, step }) this.stepOpen = false diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8799bc3664..6026c917c6 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -65,6 +65,7 @@ The handle every plugin programs against: - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. +- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. - `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 525653776f..1acf8c5ae5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -236,6 +236,16 @@ export interface Agent { */ steer(message: UserMessage): void + /** + * Atomically submit steering only while the current step still owns its final + * drain. Returns `false` without accepting the message during admission, + * between steps, or after the final per-step drain has begun. Cancellation or + * disposal may still discard previously accepted steering. + * @param message - identified steering content and its producer provenance. + * @returns whether the message entered the current step. + */ + trySteer?(message: UserMessage): boolean + /** * Append model-facing context without running the model — the * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index b1aa00ab02..f8c161bcb8 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -181,7 +181,12 @@ export class SubagentControlService extends Service { activation.controller.abort('subagent control service disposed') activation.terminal.resolve() } - await Promise.allSettled(active.map(activation => activation.done ?? Promise.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 + })) }, 'subagentControl.activations()') } @@ -354,7 +359,8 @@ export class SubagentControlService extends Service { const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) if (descriptor === undefined) { throw new SubagentControlError( - `subagent "${childId}" has no supported continuation descriptor`, + `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + + 'do not retry send_message with this id', 'NOT_RESUMABLE', ) } diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 391b94a30a..97adf790a6 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -89,6 +89,20 @@ 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 control = ctx.subagentControl as unknown as { + activations: Map + } + await new Promise((resolve) => { + const timer = setInterval(() => { + if (control.activations.get(childId)?.run !== undefined) { + clearInterval(timer) + resolve() + } + }, 5) + }) +} + function message(text: string) { return [{ type: 'text' as const, text }] } @@ -145,6 +159,20 @@ describe('SubagentControlService.startContinuable', () => { expect(ctx.tasks.list(parent)).toEqual([]) }) + it('rolls back the activation when Task preflight throws', 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.subagentControl.startContinuable(startSpec(parent))) + .toThrow('task preflight failed') + } finally { + ctx.tasks.start = realStart + } + const control = ctx.subagentControl as unknown as { activations: Map } + expect(control.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) @@ -195,6 +223,85 @@ describe('SubagentControlService.startContinuable', () => { }) describe('SubagentControlService.sendMessage', () => { + 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.subagentControl.startContinuable(startSpec(parent, 'no-steer')) + await waitPublishedRun(ctx, started.childId) + + expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + .toThrow(/provider does not accept live delivery/) + + let terminalDeliveryError: unknown + ctx.tasks.onTaskDone((snapshot) => { + if (snapshot.id !== started.taskId) return + try { + ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal')) + } catch (error: unknown) { + terminalDeliveryError = error + } + }) + result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + await waitTerminal(ctx, started.taskId, parent) + 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.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) + await waitPublishedRun(ctx, started.childId) + + expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + .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 @@ -358,7 +465,23 @@ describe('SubagentControlService.sendMessage', () => { const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') - expect(snapshot.detail).toContain('continuation descriptor') + 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 = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' ')) + const longText = 'x'.repeat(100) + const long = ctx.subagentControl.sendMessage(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 control-service ownership', async () => { @@ -491,7 +614,7 @@ describe('service disposal with live activations', () => { await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(TaskService) + 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 control service is disposed. @@ -518,7 +641,7 @@ describe('service disposal with live activations', () => { label: 'will be interrupted', request: { prompt: message('go'), parent }, }) - // TaskService keeps the producer Task; the disposing control service must + // LocalTaskService keeps the producer Task; the disposing control service must // cancel its activation and await settlement rather than strand it. await controlFiber.dispose() expect(sawAbort).toBe(true) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 43357250cf..a71ee4a05b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -276,10 +276,10 @@ function driveTurn( if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } - // Terminal turn-stops only run between steps: with no step open, the - // loop may be awaiting its continuation/turn-stop checkpoints, where - // pending steering was already folded and a terminal decision discards - // a later arrival. A message accepted during an OPEN step is instead + // Turn settlement only runs between steps: with no step open, the loop + // may be awaiting its continuation/turn-stopping checkpoint, where + // pending steering was already folded and a later arrival would miss + // this turn. A message accepted during an OPEN step is instead // drained and recorded at that step's settlement checkpoint before any // terminal decision (cancellation remains the documented shared-outcome // race). @@ -289,14 +289,20 @@ function driveTurn( if (lastStep?.type !== 'step/start') { throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) } - // A committed structured capture makes the pending `agent/turn-stop` - // checkpoint terminal, and the loop then discards late steering. The - // capture is synchronously observable, so reject rather than - // acknowledge a message the run is about to drop. + // A committed structured capture makes the pending step conclusion + // terminal. The capture is synchronously observable, so reject rather + // than acknowledge a message the run is about to drop. if (structured?.captured() !== undefined) { throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) } - child.steer(createUserMessage({ content, source: { kind: 'user' } })) + // The atomic Agent operation closes before the final drain, so this + // cannot acknowledge content that the current step will not record. + if (child.trySteer === undefined) { + throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) + } + if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) { + throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) + } }, } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 62ef25e095..3ed95d159d 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -121,28 +121,25 @@ describe('in-process structured output', () => { }) it('strict steer rejects delivery once the structured result is captured', async () => { - // Hold the capture's tool result open so the child is observably running - // with a committed capture: the pending agent/turn-stop checkpoint is - // terminal, and the loop would DISCARD a steering message, so an - // acknowledged delivery here would be a lie. - let releaseResult: (() => void) | undefined const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) - ctx.on('agent/post-step', (agent) => { - if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return - return new Promise((resolve) => { releaseResult = resolve }) + let run: Awaited> | undefined + let rejected: unknown + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined || run === undefined + || event.type !== 'tool/result' || rejected !== undefined) return + try { + run.steer?.([{ type: 'text', text: 'one more thing' }]) + } catch (error: unknown) { + rejected = error + } }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseResult !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) }) - .toThrow(/already reported its structured result; the message was not delivered/) - releaseResult!() + run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result + expect(rejected).toBeInstanceOf(Error) + expect((rejected as Error).message) + .toMatch(/already reported its structured result; the message was not delivered/) expect(result.structured).toEqual({ answer: 7 }) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 03202a6707..aaa4ac82d4 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -2,16 +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 { SessionId } from '@deepseek-ai/dsh-session' +import { Session, 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 from '@deepseek-ai/dsh-subagent' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -186,6 +186,61 @@ 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' }], + 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') + const child = { + id: childId, + options: {}, + session: new Session(childId), + status: 'idle', + acceptsNextStep: false, + ctx: new Context(), + send(): void {}, + reserveTurnAdmission: () => undefined, + updateInbox: () => 'not-found', + followup(): void {}, + steer(): void {}, + 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' }], + parent, + signal: new AbortController().signal, + descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, + }) + expect(resumedOptions).toEqual({}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + await run.dispose() + }) + it('uses the request signal after publication and dispose as cancellation paths', async () => { const { parent, adapter } = await setup(['hang', 'hang']) const controller = new AbortController() @@ -258,14 +313,12 @@ describe('startInProcessRun', () => { await run.dispose() }) - it('strict steer rejects the between-steps window where a terminal turn-stop discards steering', async () => { - // Hold `agent/turn-stop` open: the step has closed, pending steering was - // already folded into the continuation decision, and a terminal stop - // would discard a message arriving now — the exact window an - // acknowledged delivery would be a lie. + it('strict steer rejects the between-steps turn-stopping window', async () => { + // Hold `agent/turn-stopping` open after the step closed and pending + // steering was folded into the continuation decision. const { ctx, parent } = await setup([textResponse('quick')]) let releaseStop: (() => void) | undefined - ctx.on('agent/turn-stop', (agent) => { + ctx.on('agent/turn-stopping', (agent) => { if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined return new Promise((resolve) => { releaseStop = () => { resolve(undefined) } @@ -287,6 +340,87 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('strict steer rejects reentrant delivery after the final drain begins', async () => { + const { ctx, parent } = await setup([textResponse('quick')]) + let run: Awaited> | undefined + let seeded = false + let rejected: unknown + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined || run === undefined) return + if (event.type === 'assistant/chunk' && !seeded) { + seeded = true + run.steer?.([{ type: 'text', text: 'accepted before the drain' }]) + } else if (event.type === 'steering/message' && rejected === undefined) { + try { + run.steer?.([{ type: 'text', text: 'after the drain began' }]) + } catch (error: unknown) { + rejected = error + } + } + }) + + run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await run.result + expect(seeded).toBe(true) + expect(rejected).toBeInstanceOf(Error) + expect((rejected as Error).message) + .toMatch(/passed its steering checkpoint; the message was not delivered/) + expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1) + await run.dispose() + }) + + it('strict steer rejects an Agent implementation without atomic steering', async () => { + const childId = SessionId('custom-loop-child') + const childSession = new Session(childId) + childSession.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + childSession.append('step/start', { turn: 1, step: 1 }) + const idle = Promise.withResolvers() + const child = { + id: childId, + options: {}, + session: childSession, + status: 'running', + acceptsNextStep: false, + ctx: new Context(), + send(): void {}, + reserveTurnAdmission: () => undefined, + updateInbox: () => 'not-found', + followup(): void {}, + steer(): void {}, + inject(): void {}, + cancel(): void {}, + whenIdle: () => idle.promise, + } as Agent + const parentId = SessionId('custom-loop-parent') + const parent = { + id: parentId, + options: {}, + session: new Session(parentId), + ctx: { + get: () => undefined, + agents: { + create: () => Promise.resolve({ + agent: child, + dispose: () => { + idle.resolve(undefined) + return Promise.resolve() + }, + }), + }, + }, + } as unknown as Agent + + const run = await startInProcessRun(request(parent), {}) + expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) }) + .toThrow(/does not support strict steering; the message was not delivered/) + await run.dispose() + await run.result + }) + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { // Hold the turn-end durability flush open: the turn has closed in the log // and status is still `running`, exactly the window where the loop would diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 5b9eeaaa0f..1c5889bdc2 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -5,6 +5,9 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, SubagentError, assertSubagentMaxDepth, type SubagentCapabilities, @@ -13,7 +16,7 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent @@ -99,6 +102,28 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) + it('rejects continuable start and resume when the provider has no resume capability', async () => { + const { subagents } = await service() + subagents.registerProvider(new StubProvider('one-shot')) + const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) + const sessionId = SessionId('continuable-child') + const parent = fakeParent() + const signal = new AbortController().signal + + await expect(subagents.start('one-shot', baseRequest({ + parent, + signal, + continuation: { sessionId, descriptor }, + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + await expect(subagents.resume('one-shot', { + sessionId, + prompt: [{ type: 'text', text: 'continue' }], + parent, + signal, + descriptor, + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + }) + it.each([ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], @@ -247,3 +272,17 @@ describe('SubagentService', () => { expect(error.code).toBe('NO_PROVIDER') }) }) + +describe('subagent descriptors', () => { + it('omits absent model selectors and rejects unsupported versions', () => { + expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + }) + const unsupported = { + type: 'subagent/descriptor', + data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }, + } as unknown as SessionEvent<'subagent/descriptor'> + expect(foldSubagentDescriptor([unsupported])).toBeUndefined() + }) +}) From 1ab3cbf673b51f1634dd4dc01b48ade7cbc75ed6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 12:39:07 +0800 Subject: [PATCH 09/90] fix(subagent): harden continuable persistence --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 10 +- ...-21-continuable-background-subagents.zh.md | 10 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 8 +- ...ent-durability-failure.cordis.snapshot.yml | 45 ++++++++ .../subagent-durability-failure.cordis.yml | 10 ++ examples/acp-agent/tests/acp.snapshot.ts | 16 ++- .../fixtures/subagent-durability-failure.ts | 14 +++ .../subagent-continuable/session.jsonl | 2 +- knip.json | 1 + packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 9 +- .../tests/subagent-control.spec.ts | 19 +++- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 9 +- .../subagent/subagent-inprocess/README.zh.md | 9 +- .../subagent/subagent-inprocess/src/index.ts | 34 +++++- .../tests/subagent-inprocess.spec.ts | 82 +++++++++++++- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/src/descriptor.ts | 101 +++++++++++++++++- packages/subagent/subagent/src/types.ts | 6 +- .../subagent/subagent/tests/service.spec.ts | 67 ++++++++++-- 23 files changed, 412 insertions(+), 56 deletions(-) create mode 100644 examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-durability-failure.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent-durability-failure.ts 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 26572951a6..a4bc2722c2 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 -2026-07-21-continuable-background-subagents.md: 287239a22c440eb4758a8dab5621406246a7e0b7 -2026-07-21-continuable-background-subagents.zh.md: 36b28581e1bf05144e9ffd5de136983eff8fdabc +2026-07-21-continuable-background-subagents.md: af7ef5c18c2af925e64b309d76e31ee079360b81 +2026-07-21-continuable-background-subagents.zh.md: 0c9e2e4d87e50ebb02cafe8f2333dca81ef8c5da 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 287239a22c..af7ef5c18c 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 @@ -29,7 +29,7 @@ The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agn ### 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()` (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. +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. @@ -77,7 +77,7 @@ Cold resume cannot depend on an optional method of the old `SubagentRun`, becaus `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. 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. 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. +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. @@ -107,10 +107,10 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the 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/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and 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.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the 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.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_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, `task_output` collection, and a `send_message` follow-up whose started Task fails with the id unavailable. +- 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 through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. ## Consequences @@ -119,6 +119,6 @@ Task records and active-run associations are process-local. Persistence makes th - 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. +- 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 `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. 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 36b28581e1..0c9e2e4d87 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 @@ -29,7 +29,7 @@ durable child Session ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。Task 结算流程等待结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。 +初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点失败时,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将后端失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 @@ -77,7 +77,7 @@ durable child Session `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。child 创建、首轮持久化或描述符持久化失败时,系统会 dispose 所有已发布的 run,并将已经创建的 Task 结算为 `failed`;模型通过普通 Task 完成通知或 `task_output` 路径观察该失败。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 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 写入持久化目录。 @@ -107,10 +107,10 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 -- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、`task_output` 结果收集,以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 +- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 ## 影响 @@ -119,6 +119,6 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 -- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、持久化失败,或进程在 child 首次 flush 之前退出,都可能留下 unmaterialized child id;按 id 的控制操作会报告该 id 不可用,持久化枚举也不会列出它,而不会追溯修改工具返回结果。 +- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 - 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 - Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9028df3814..e73ce42c82 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1988,7 +1988,7 @@ sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMes 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) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:156`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:163`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b5ec558351..b547305e8d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -203,7 +203,7 @@ interface SubagentStopReasonMap { ## A live 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. 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`. +`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 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 /** @@ -228,8 +228,10 @@ 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. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `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. */ readonly result: Promise /** diff --git a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml new file mode 100644 index 0000000000..7ce0733e53 --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless counterpart to subagent-durability-failure.cordis.yml: replace the +# live adapter with replay and fail the provider-owned final child checkpoint. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/subagent-durability-failure.cordis.yml b/examples/acp-agent/subagent-durability-failure.cordis.yml new file mode 100644 index 0000000000..c033c323dc --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.yml @@ -0,0 +1,10 @@ +# Snapshot-only durability-failure overlay. The child turn's ordinary flush +# succeeds; the provider-owned final confirmation fails deterministically. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index aa4bad392c..c2e0328fab 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -41,6 +41,9 @@ const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) +const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( + new URL('../subagent-durability-failure.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -214,10 +217,15 @@ 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, task_output collects the - // child result after settlement, and send_message to an unknown subagent id - // starts a follow-up task that settles failed with the id unavailable. - { name: 'subagent-continuable', hasModelTurn: true, recorded: false }, + // 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. + { + name: 'subagent-continuable', + hasModelTurn: true, + recorded: false, + configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, + }, { name: 'subagent-depth-two-rejection', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts new file mode 100644 index 0000000000..5d0137911d --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -0,0 +1,14 @@ +import type { Context } from 'cordis' + +export const name = 'subagent-durability-failure' + +/** Fail a continuable child's provider-owned final durability confirmation. */ +export function apply(ctx: Context): void { + const flushedTurnEnds = new WeakSet() + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + if (session.events.at(-1)?.type !== 'turn/end') return + if (flushedTurnEnds.has(session)) throw new Error('snapshot disk full') + flushedTurnEnds.add(session) + }) +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index e9e859d905..1b6e576b54 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -23,7 +23,7 @@ {"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":"CHILD_OK\n[status: completed]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"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"}}} diff --git a/knip.json b/knip.json index 43102b0b0d..9f2c7db4a3 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index 002613d508..e65c6dcf1c 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -4,7 +4,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches ## Activation lifecycle -A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. +A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. `sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index f8c161bcb8..ab0fab6d9d 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -116,6 +116,13 @@ export function runOutcome(result: SubagentResult): TaskOutcome { } } +/** 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. @@ -127,7 +134,7 @@ export async function settleRun(run: SubagentRun): Promise { try { outcome = runOutcome(await run.result) } catch (error: unknown) { - outcome = { status: 'failed', detail: String(error) } + outcome = { status: 'failed', detail: runFailureDetail(error) } } try { await run.dispose() diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 97adf790a6..458e6a6960 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -16,7 +16,7 @@ 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, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' @@ -685,16 +685,29 @@ describe('outcome mapping helpers', () => { expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) expect(disposed).toBe(true) - const disposeFailed = await settleRun({ + 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-4'), + id: SessionId('child-5'), localAgent: undefined, result: Promise.reject(new Error('result failed')), dispose: () => Promise.reject(new Error('reap failed')), diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index bd951115d2..2b5ea80435 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: 7587b6dfc44bef90756c9f2aba96d54872935fee -README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d +README.md: eb5d973566f01c05b43f4f56eff746b7af93f60b +README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 526b237efa..eb5d973566 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -12,9 +12,10 @@ 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 one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. +3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. 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. 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. @@ -22,7 +23,7 @@ When the optional sandbox-policy or approval service is composed, the driver sna ## 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, abort handoff, and disposal follow the same contract as start. +`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 @@ -30,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica 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 the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), an open step (between steps the loop may sit at its continuation/turn-stop checkpoints, where steering was already folded and a terminal stop discards a later arrival; a message accepted during an open step is recorded at that step's settlement before any terminal decision), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 751e745c6c..5be640f9b6 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -12,9 +12,10 @@ 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。 +5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为。 +6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 @@ -22,7 +23,7 @@ ## 冷恢复 -`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。 +`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。 ## 取消与所有权 @@ -30,7 +31,7 @@ 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开严格的 `steer` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 +运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 ## Spawn 与 fork 输入 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index a71ee4a05b..aaad34ccf8 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -11,8 +11,8 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' +import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, SubagentResult, @@ -67,6 +67,9 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } +/** Whether one activation must prove its final state durable before success. */ +type Durability = 'best-effort' | 'required' + /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') @@ -168,7 +171,15 @@ export async function startInProcessRun( signal: request.signal, setup, }) - return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured) + return driveTurn( + handle, + request.signal, + request.prompt, + childId, + seedLength, + request.continuation === undefined ? 'best-effort' : 'required', + structured, + ) } /** @@ -203,14 +214,15 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis // 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) + return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint, 'required') } /** * 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`, strict steering, and disposal. + * `boundary`, the continuable-run durability confirmation, strict steering, + * and disposal. */ function driveTurn( handle: AgentHandle, @@ -218,6 +230,7 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, + durability: Durability, structured?: StructuredAttachment, ): SubagentRun | Promise { const child = handle.agent @@ -238,6 +251,17 @@ function driveTurn( try { child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) await child.whenIdle() + if (durability === 'required') { + try { + await child.ctx.sessions.flush(child.session) + } catch (error: unknown) { + 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, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index aaa4ac82d4..bcec292d93 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -9,7 +9,7 @@ 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 } from '@deepseek-ai/dsh-subagent' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' @@ -38,6 +38,22 @@ 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('') } @@ -56,6 +72,59 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + 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('keeps foreground runs best-effort when their turn checkpoint fails', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + throw new Error('disk full') + }) + + const run = await startInProcessRun(request(parent), {}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(flushes).toBe(1) + await run.dispose() + }) + it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) let injected = false @@ -201,13 +270,21 @@ describe('startInProcessRun', () => { 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: new Context(), + ctx: { + sessions: { + flush: () => { + flushes++ + return Promise.resolve() + }, + }, + } as unknown as Context, send(): void {}, reserveTurnAdmission: () => undefined, updateInbox: () => 'not-found', @@ -238,6 +315,7 @@ describe('startInProcessRun', () => { }) expect(resumedOptions).toEqual({}) await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(flushes).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c7bf9af45a..68003363cd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -49,7 +49,7 @@ Runtime features are optional methods whose presence is the capability check: `S ## 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()` recovers it from a loaded child log. 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 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. ## Delegation depth @@ -61,7 +61,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `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. -`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. +`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. 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 control-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index c837a696b8..00942ca448 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -71,6 +71,102 @@ export interface SubagentDescriptorInput { readonly toolFilter?: ToolRestriction } +const DESCRIPTOR_KEYS = new Set([ + 'version', + 'provider', + 'agentProvider', + 'agentModel', + 'persona', + 'toolFilter', +]) +const TOOL_FILTER_KEYS = new Set(['allow', 'deny']) + +/** Whether a persisted JSON value is an object record. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Reject fields outside one versioned record's declared schema. */ +function assertKnownKeys(value: Record, keys: ReadonlySet, path: string): void { + const unknown = Object.keys(value).find(key => !keys.has(key)) + if (unknown !== undefined) { + throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`) + } +} + +/** Read one optional string field from a persisted descriptor record. */ +function optionalString(value: Record, key: string): string | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (typeof field !== 'string') { + throw new Error(`persisted subagent descriptor ${key} must be a string`) + } + return field +} + +/** Read one optional string-array field from a persisted tool restriction. */ +function optionalStringArray(value: Record, key: string): string[] | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (!Array.isArray(field)) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + const items: unknown[] = field + if (items.some(item => typeof item !== 'string')) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + return items as string[] +} + +/** Validate and reconstruct a persisted tool restriction. */ +function parseToolFilter(value: unknown): ToolRestriction { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor toolFilter must be an object') + } + assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter') + const allow = optionalStringArray(value, 'allow') + const deny = optionalStringArray(value, 'deny') + if (allow === undefined && deny === undefined) { + throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny') + } + return { + ...allow !== undefined ? { allow } : {}, + ...deny !== undefined ? { deny } : {}, + } +} + +/** Validate one persisted descriptor payload for the current runtime. */ +function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor payload must be an object') + } + const version = value['version'] + if (typeof version !== 'number') { + throw new Error('persisted subagent descriptor version must be a number') + } + if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined + + assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload') + const provider = value['provider'] + if (typeof provider !== 'string') { + throw new Error('persisted subagent descriptor provider must be a string') + } + const agentProvider = optionalString(value, 'agentProvider') + const agentModel = optionalString(value, 'agentModel') + const persona = optionalString(value, 'persona') + const toolFilter = Object.hasOwn(value, 'toolFilter') + ? parseToolFilter(value['toolFilter']) + : undefined + return { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...persona !== undefined ? { persona } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + } +} + /** * Validate and detach descriptor inputs into the durable payload, before any * Task or provider work begins — the same detached lossless-JSON boundary the @@ -105,12 +201,13 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba * @returns the descriptor, or `undefined` when the log has none or its * version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not * resumable by this runtime). + * @throws when a current-version persisted payload does not match its complete + * declared schema. */ export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined { const event = events.find( (candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor', ) if (event === undefined) return undefined - if (event.data.version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined - return event.data + return parseSubagentDescriptor(event.data) } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 8cc88eb0c2..0b76723e26 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -207,8 +207,10 @@ 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. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `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. */ readonly result: Promise /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 1c5889bdc2..d04599a4d2 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -274,15 +274,68 @@ describe('SubagentService', () => { }) describe('subagent descriptors', () => { - it('omits absent model selectors and rejects unsupported versions', () => { - expect(snapshotSubagentDescriptor({ provider: 'spawn' })).toEqual({ + const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({ + type: 'subagent/descriptor', + data, + } as unknown as SessionEvent<'subagent/descriptor'>) + + it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => { + expect(foldSubagentDescriptor([])).toBeUndefined() + const minimal = snapshotSubagentDescriptor({ provider: 'spawn' }) + expect(minimal).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', }) - const unsupported = { - type: 'subagent/descriptor', - data: { version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }, - } as unknown as SessionEvent<'subagent/descriptor'> - expect(foldSubagentDescriptor([unsupported])).toBeUndefined() + expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal) + const complete = { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'deepseek', + agentModel: 'chat', + persona: 'reviewer', + toolFilter: { allow: ['read'], deny: ['bash'] }, + } + expect(snapshotSubagentDescriptor({ + provider: complete.provider, + agentProvider: complete.agentProvider, + agentModel: complete.agentModel, + persona: complete.persona, + toolFilter: complete.toolFilter, + })).toEqual(complete) + expect(foldSubagentDescriptor([event(complete)])).toEqual(complete) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }), + ])).toMatchObject({ toolFilter: { allow: ['read'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }), + ])).toMatchObject({ toolFilter: { deny: ['bash'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }), + ])).toBeUndefined() + expect(() => snapshotSubagentDescriptor({ + provider: 'spawn', + toolFilter: { deny: [Symbol('not-json')] as unknown as string[] }, + })).toThrow('not losslessly JSON-serializable') + }) + + it.each([ + ['string payload', 'invalid', 'payload must be an object'], + ['null payload', null, 'payload must be an object'], + ['array payload', [], 'payload must be an object'], + ['missing version', { provider: 'spawn' }, 'version must be a number'], + ['string version', { version: '1', provider: 'spawn' }, 'version must be a number'], + ['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'], + ['missing provider', { version: 1 }, 'provider must be a string'], + ['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'], + ['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'], + ['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'], + ['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'], + ['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'], + ['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'], + ['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'], + ['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'], + ['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'], + ])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => { + expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail) }) }) From 189502e4ac65bffd86bcc42a69ee0fa6a936f605 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 13:18:35 +0800 Subject: [PATCH 10/90] fix(subagent): preserve follow-up provenance --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 8 +- ...-21-continuable-background-subagents.zh.md | 8 +- docs/cordis-catalog/services.md | 9 ++- docs/core-data-structures/subagent.md | 21 +++++- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 38 ++++++++-- .../tests/subagent-control.spec.ts | 73 +++++++++++++------ .../subagent/subagent-inprocess/src/index.ts | 59 +++++++++------ .../tests/structured.spec.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 18 +++-- .../tests/subagent-spawn.spec.ts | 2 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 1 + .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/src/index.ts | 7 +- .../tests/tool-subagent-control.spec.ts | 13 +++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 20 files changed, 202 insertions(+), 87 deletions(-) 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 a4bc2722c2..10165b1d7a 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 -2026-07-21-continuable-background-subagents.md: af7ef5c18c2af925e64b309d76e31ee079360b81 -2026-07-21-continuable-background-subagents.zh.md: 0c9e2e4d87e50ebb02cafe8f2333dca81ef8c5da +2026-07-21-continuable-background-subagents.md: 6552db82dc5cf1fabac8f18dd347cc8735f73587 +2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb 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 af7ef5c18c..6552db82dc 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 @@ -55,9 +55,9 @@ The control service does not serialize two callers that race a stopped child thr ### 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 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. +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)` and returns the existing Task id; it creates no Task of its own. +- 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_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. @@ -107,8 +107,8 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and 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.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task; cold follow-ups accumulate turns in one durable transcript with the 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.ts` pins the `send_message` schema, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and 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.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after 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.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_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 through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. 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 0c9e2e4d87..ed07abd2af 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 @@ -55,9 +55,9 @@ durable child Session ### 面向模型的 `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 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;控制服务要求调用方提供 `MessageSource`,并在在线 steering 与 cold resume 两条路径中传递该来源。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }`。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 -- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 - 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 @@ -107,8 +107,8 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,而不创建第二个 Task;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 -- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e73ce42c82..76ee56bfb3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1980,15 +1980,16 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @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. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ -sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult +sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult ``` -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) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/subagent/subagent-control/src/index.ts:163`](../../packages/subagent/subagent-control/src/index.ts) +Source: [`packages/subagent/subagent-control/src/index.ts:176`](../../packages/subagent/subagent-control/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b547305e8d..b746073424 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -6,7 +6,7 @@ The subagent seam — an agent delegating work to a child agent. Like [bash](bas 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) +Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) and [`packages/subagent/subagent-control/src/index.ts`](../../packages/subagent/subagent-control/src/index.ts) ## Two kinds of capability, discovered two ways @@ -105,7 +105,16 @@ interface SubagentStartRequest { ## 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. +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. Every sender supplies a `MessageSource`; the model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. Both project to a user-role model message, but the durable source remains distinct for policy and title consumers. + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` ```ts type-equiv /** @@ -134,6 +143,8 @@ interface SubagentResumeRequest { 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 @@ -203,7 +214,7 @@ interface SubagentStopReasonMap { ## A live 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 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`. +`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 strict `steer` method advertises live delivery by presence. 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. ```ts type-equiv /** @@ -249,8 +260,10 @@ interface SubagentRun { * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the child's logged steering message without + * changing its user role in model history. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b090b438e6..749ba48359 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', }, { - signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the content to deliver.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', }, ], }, @@ -2699,11 +2699,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResumeRequest', - declaration: 'export interface SubagentResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + declaration: 'export interface SubagentResumeRequest {\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: '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[]): void;\n}', + 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): void;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index e65c6dcf1c..141333f719 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. -`sendMessage(parent, childId, message)` owns steer-or-resume routing. A running activation receives live delivery through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` (`started`). Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. +`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index ab0fab6d9d..9de5459451 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -20,7 +20,7 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } 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 '@deepseek-ai/dsh-subagent' @@ -33,6 +33,19 @@ declare module 'cordis' { } } +/** Attribution for a model coordinator's follow-up to one of its children. */ +export interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + coordinator: CoordinatorMessageSource + } +} + /** Typed error for control-service routing, authorization, and delivery failures. */ export class SubagentControlError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { @@ -248,16 +261,20 @@ export class SubagentControlService extends Service { * @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. - * @param message - the content to deliver. + * @param message - the user-role content to deliver. + * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ - sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[]): SendMessageResult { + sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { - return { route: 'steered', taskId: this.steerActivation(activation, parent, childId, message) } + return { + route: 'steered', + taskId: this.steerActivation(activation, parent, childId, message, source), + } } - return { route: 'started', taskId: this.resumeActivation(parent, childId, message) } + return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } } /** @@ -290,6 +307,7 @@ export class SubagentControlService extends Service { parent: Agent, childId: SessionId, message: ContentBlock[], + source: MessageSource, ): TaskId { 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. */ @@ -316,7 +334,7 @@ export class SubagentControlService extends Service { ) } try { - run.steer(message) + run.steer(message, source) } catch (error: unknown) { // Strict steering lost the race with turn settlement. Deliberately no // cold-resume fallback here: that would attach the message to a turn the @@ -337,7 +355,12 @@ export class SubagentControlService extends Service { * activation, with cancellation rechecked after the un-signalled * persistence await so an early `task_kill` prevents any later child work. */ - private resumeActivation(parent: Agent, childId: SessionId, message: ContentBlock[]): TaskId { + private resumeActivation( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): TaskId { const persistence = this.requirePersistence() return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { let loaded: Awaited> @@ -374,6 +397,7 @@ export class SubagentControlService extends Service { return this.ctx.subagents.resume(descriptor.provider, { sessionId: childId, prompt: message, + source, parent, signal, descriptor, diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index 458e6a6960..c81b88b756 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -107,6 +107,20 @@ function message(text: string) { return [{ type: 'text' as const, text }] } +const coordinatorSource = { + kind: 'coordinator', + senderSessionId: SessionId('parent'), +} as const + +function sendMessage( + ctx: Context, + parent: Agent, + childId: SessionId, + content: ReturnType, +) { + return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' }) +} + describe('SubagentControlService.startContinuable', () => { it('returns both identities immediately; the Task settles with the child result after disposal', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) @@ -202,7 +216,7 @@ describe('SubagentControlService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('hello?')) + const followUp = sendMessage(ctx, parent, started.childId, message('hello?')) expect(followUp.route).toBe('started') const failed = await waitTerminal(ctx, followUp.taskId, parent) expect(failed.status).toBe('failed') @@ -250,14 +264,14 @@ describe('SubagentControlService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .toThrow(/provider does not accept live delivery/) let terminalDeliveryError: unknown ctx.tasks.onTaskDone((snapshot) => { if (snapshot.id !== started.taskId) return try { - ctx.subagentControl.sendMessage(parent, started.childId, message('after terminal')) + sendMessage(ctx, parent, started.childId, message('after terminal')) } catch (error: unknown) { terminalDeliveryError = error } @@ -296,7 +310,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('join'))) + expect(() => sendMessage(ctx, parent, started.childId, message('join'))) .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) @@ -324,7 +338,12 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - const delivered = ctx.subagentControl.sendMessage(parent, started.childId, message('also consider Y')) + const delivered = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('also consider Y'), + coordinatorSource, + ) expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) releaseFirst() const snapshot = await waitTerminal(ctx, started.taskId, parent) @@ -334,6 +353,11 @@ describe('SubagentControlService.sendMessage', () => { // 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('cold-resumes a settled child into a fresh Task and reports `started`', async () => { @@ -342,7 +366,12 @@ describe('SubagentControlService.sendMessage', () => { await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('and then?')) + const followUp = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('and then?'), + coordinatorSource, + ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) const snapshot = await waitTerminal(ctx, followUp.taskId, parent) @@ -356,6 +385,8 @@ describe('SubagentControlService.sendMessage', () => { 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 () => { @@ -378,7 +409,7 @@ describe('SubagentControlService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('continue')) + const followUp = sendMessage(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. @@ -407,7 +438,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = sendMessage(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. @@ -423,7 +454,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('go on')) + const followUp = sendMessage(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -443,7 +474,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = ctx.subagentControl.sendMessage(parent, started.childId, message('mine now')) + const attempt = sendMessage(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') @@ -462,7 +493,7 @@ describe('SubagentControlService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = ctx.subagentControl.sendMessage(parent, SessionId('plain-child'), message('continue?')) + const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain( @@ -472,9 +503,9 @@ describe('SubagentControlService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = ctx.subagentControl.sendMessage(parent, SessionId('blank-child'), message(' ')) + const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = ctx.subagentControl.sendMessage(parent, SessionId('long-child'), message(longText)) + const long = sendMessage(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)}…`) @@ -492,9 +523,9 @@ describe('SubagentControlService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(SubagentControlError) - expect(() => ctx.subagentControl.sendMessage(parent, SessionId('rogue-child'), message('hello'))) + expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .toThrow(/outside control-service ownership.*not delivered/) await handle.dispose() }) @@ -535,13 +566,13 @@ describe('SubagentControlService.sendMessage', () => { // Strict steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('too late?'))) + expect(() => sendMessage(ctx, parent, started.childId, message('too late?'))) .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 = ctx.subagentControl.sendMessage(parent, started.childId, message('retry')) + const retry = sendMessage(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -550,7 +581,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('more')) + const followUp = sendMessage(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/) }) @@ -569,7 +600,7 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const followUp = ctx.subagentControl.sendMessage(parent, started.childId, message('follow up')) + const followUp = sendMessage(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) @@ -591,11 +622,11 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const first = ctx.subagentControl.sendMessage(parent, started.childId, message('first follow-up')) + const first = sendMessage(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. - expect(() => ctx.subagentControl.sendMessage(parent, started.childId, message('second follow-up'))) + expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up'))) .toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index aaad34ccf8..f48257ad02 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' -import { createUserMessage, errorChain, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, @@ -70,6 +70,14 @@ export interface InProcessRunOptions { /** 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') @@ -177,8 +185,10 @@ export async function startInProcessRun( request.prompt, childId, seedLength, - request.continuation === undefined ? 'best-effort' : 'required', - structured, + { + durability: request.continuation === undefined ? 'best-effort' : 'required', + ...structured === undefined ? {} : { structured }, + }, ) } @@ -214,7 +224,14 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis // 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, 'required') + return driveTurn( + handle, + request.signal, + request.prompt, + request.sessionId, + resumePoint, + { durability: 'required', source: request.source }, + ) } /** @@ -230,10 +247,10 @@ function driveTurn( prompt: ContentBlock[], childId: SessionId, boundary: number, - durability: Durability, - structured?: StructuredAttachment, + options: DriveTurnOptions, ): 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) { @@ -249,7 +266,7 @@ function driveTurn( const result: Promise = (async () => { try { - child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } })) + child.followup(createUserMessage({ content: prompt, source: source ?? { kind: 'user' } })) await child.whenIdle() if (durability === 'required') { try { @@ -282,31 +299,27 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous checks and the Agent.steer() - // call share one frame, so delivery joins the observed turn or throws. - // Agent.steer()'s own idle fallback would instead QUEUE the message and + steer(content: ContentBlock[], steeringSource: MessageSource): void { + // Strict live delivery: the synchronous checks and Agent.trySteer() share + // one frame, so delivery joins the observed step or throws. The ordinary + // Agent.steer() idle fallback would instead queue the message and // start a new, untracked turn after this run's result was read. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } - // The status stays `running` through the closed turn's durability flush, - // and the loop DISCARDS terminal-stopped steering drained after turn - // close instead of recording it. Requiring an open turn keeps - // acknowledged delivery honest. + // Status stays `running` through the closed turn's durability flush, when + // ordinary steering would queue a later turn. Requiring an open turn + // keeps this activation's acknowledged delivery honest. const lastBoundary = child.session.events.findLast( event => event.type === 'turn/start' || event.type === 'turn/end', ) if (lastBoundary?.type !== 'turn/start') { throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) } - // Turn settlement only runs between steps: with no step open, the loop - // may be awaiting its continuation/turn-stopping checkpoint, where - // pending steering was already folded and a later arrival would miss - // this turn. A message accepted during an OPEN step is instead - // drained and recorded at that step's settlement checkpoint before any - // terminal decision (cancellation remains the documented shared-outcome - // race). + // Between steps there is no current step whose final drain can own strict + // delivery. A message accepted during an open step is recorded at that + // step's settlement checkpoint before the continuation decision + // (cancellation remains the documented shared-outcome race). const lastStep = child.session.events.findLast( event => event.type === 'step/start' || event.type === 'step/end', ) @@ -324,7 +337,7 @@ function driveTurn( if (child.trySteer === undefined) { throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) } - if (!child.trySteer(createUserMessage({ content, source: { kind: 'user' } }))) { + if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) { throw new Error(`subagent child "${childId}" passed its steering checkpoint; the message was not delivered`) } }, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3ed95d159d..a9ef98a892 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -130,7 +130,7 @@ describe('in-process structured output', () => { if (session.header.parentSession === undefined || run === undefined || event.type !== 'tool/result' || rejected !== undefined) return try { - run.steer?.([{ type: 'text', text: 'one more thing' }]) + run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index bcec292d93..b26ca6b84d 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -262,6 +262,7 @@ describe('startInProcessRun', () => { 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' }, @@ -309,6 +310,7 @@ describe('startInProcessRun', () => { 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' }, @@ -384,7 +386,7 @@ describe('startInProcessRun', () => { const run = await startInProcessRun(request(parent), {}) await run.result // The child is idle after its turn: Agent.steer() would silently QUEUE. - expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .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) @@ -410,7 +412,9 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'too late for this turn' }]) }) + expect(() => { + run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' }) + }) .toThrow(/between steps; the message was not delivered/) releaseStop!() await run.result @@ -427,10 +431,10 @@ describe('startInProcessRun', () => { if (session.header.parentSession === undefined || run === undefined) return if (event.type === 'assistant/chunk' && !seeded) { seeded = true - run.steer?.([{ type: 'text', text: 'accepted before the drain' }]) + run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' }) } else if (event.type === 'steering/message' && rejected === undefined) { try { - run.steer?.([{ type: 'text', text: 'after the drain began' }]) + run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' }) } catch (error: unknown) { rejected = error } @@ -493,7 +497,9 @@ describe('startInProcessRun', () => { } as unknown as Agent const run = await startInProcessRun(request(parent), {}) - expect(() => { run.steer!([{ type: 'text', text: 'unsupported strict delivery' }]) }) + expect(() => { + run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' }) + }) .toThrow(/does not support strict steering; the message was not delivered/) await run.dispose() await run.result @@ -520,7 +526,7 @@ describe('startInProcessRun', () => { }, 5) }) expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) }) .toThrow(/turn has already closed; the message was not delivered/) releaseFlush!() await run.result diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 1e43b074b4..8b55dd25f9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -246,7 +246,7 @@ describe('dsh-subagent-spawn', () => { // Strict 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). - expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) .toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0b76723e26..0806a01554 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,7 +6,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' import type { SubagentDescriptorData } from './descriptor.ts' @@ -128,6 +128,8 @@ export interface SubagentResumeRequest { 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 @@ -228,8 +230,10 @@ export interface SubagentRun { * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the child's logged steering message without + * changing its user role in model history. */ - steer?(content: ContentBlock[]): void + steer?(content: ContentBlock[], source: MessageSource): void } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index d04599a4d2..69dacf70f6 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -118,6 +118,7 @@ describe('SubagentService', () => { await expect(subagents.resume('one-shot', { sessionId, prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, parent, signal, descriptor, diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 7654012145..6a6026e3fd 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. -The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the control 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 renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 3c537f471c..959ff8eb49 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -67,7 +67,12 @@ export function apply(ctx: Context): void { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message) + const result = ctx.subagentControl.sendMessage( + parent, + SessionId(args.subagent_id), + message, + { kind: 'coordinator', senderSessionId: parent.id }, + ) return Promise.resolve(result) }, })) 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 b54eb6508a..927162d21d 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 @@ -83,17 +83,27 @@ describe('dsh-tool-subagent-control', () => { 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]') + const loaded = await ctx.sessionPersistence.load(started.childId) + const followUp = loaded.events.findLast(event => + event.type === 'user/message', + ) + 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 control service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagentControl.sendMessage = (agent, _childId, message) => { + ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text + source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } } const result = await callTool(ctx, 'send_message', { @@ -102,6 +112,7 @@ describe('dsh-tool-subagent-control', () => { }, 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') }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8c7ee2715..c8aeb86a39 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -162,6 +162,7 @@ export const LINK_MAP: Readonly> = { SpillRef: 'spill.md', ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', + CoordinatorMessageSource: 'subagent.md', SendMessageResult: 'subagent.md', SubagentProvider: 'subagent.md', SubagentResumeRequest: 'subagent.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7dfd3195a8..9e8f68177d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1099,6 +1099,11 @@ "symbol": "SubagentContinuation", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "CoordinatorMessageSource", + "source": "packages/subagent/subagent-control/src/index.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResumeRequest", From e1f7eeeb955e00243a971cdd5fdc43546d44df17 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 14:32:19 +0800 Subject: [PATCH 11/90] fix(subagent): confirm steering request admission --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 8 +- ...-21-continuable-background-subagents.zh.md | 8 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 14 +- docs/architecture.zh.md | 14 +- docs/cordis-catalog/events.md | 32 +-- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/core.md | 30 +-- docs/core-data-structures/subagent.md | 27 +-- docs/event-producer-consumer.md | 32 +-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tmux-context/tests/tmux-context.spec.ts | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/README.zh.md | 2 + packages/core/agent-loop/src/agent.ts | 222 ++++++++++++----- packages/core/agent-loop/tests/agent.spec.ts | 3 +- packages/core/agent-loop/tests/loop.spec.ts | 11 +- packages/core/agent/README.md | 3 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 42 ++-- packages/core/agent/tests/agent.spec.ts | 2 +- .../tests/tools.spec.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 2 +- packages/goal/goal/tests/projection.spec.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tool-bash-persistent/tests/tools.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 +- packages/subagent/subagent-control/README.md | 2 +- .../subagent/subagent-control/src/index.ts | 23 +- .../tests/subagent-control.spec.ts | 130 +++++++--- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/README.zh.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 47 +--- .../tests/structured.spec.ts | 20 +- .../tests/subagent-inprocess.spec.ts | 226 +++++++----------- .../tests/subagent-spawn.spec.ts | 8 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 4 +- packages/subagent/subagent/src/types.ts | 23 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/ui/tui/tests/harness.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 12 +- scripts/doc-budgets.manifest.json | 2 +- 58 files changed, 565 insertions(+), 480 deletions(-) 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 10165b1d7a..5c1d407e1d 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 -2026-07-21-continuable-background-subagents.md: 6552db82dc5cf1fabac8f18dd347cc8735f73587 -2026-07-21-continuable-background-subagents.zh.md: ed07abd2af34397d056cc022fc451e6397964acb +2026-07-21-continuable-background-subagents.md: b5683f7e4a81a65b176ff4b4306c1ad0b761cc58 +2026-07-21-continuable-background-subagents.zh.md: 0b0f22d0945bf270267df1698b9145f0ab4b04f1 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 6552db82dc..b5683f7e4a 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 @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c 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 strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks followed by the default Agent loop's optional atomic `trySteer()`: the child must be `running`, its turn and step must still be open in the log, the step's final steering drain must not have begun, and no structured capture may have committed. The loop closes `trySteer()` acceptance before draining and entering `agent/post-step`, so a terminal stop cannot discard an acknowledged message from that window. A loop without `trySteer()` cannot back strict in-process delivery. 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 operation, `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. +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. @@ -59,7 +59,7 @@ The model receives one `send_message(subagent_id, message)` tool backed by `Suba - 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_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. +- If the active provider cannot accept live delivery, confirmed steering loses its admission race, 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. @@ -73,7 +73,7 @@ The control service snapshots every descriptor input with the seam's `snapshotSu The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/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()`](../../../../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. `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 strict live-only contract cannot be confused with service orchestration or the model-facing tool. +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. @@ -97,7 +97,7 @@ Task records and active-run associations are process-local. Persistence makes th **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. +**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. 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 ed07abd2af..0b0f22d094 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 @@ -49,7 +49,7 @@ durable child Session 每个可继续 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 循环所提供的可选原子操作 `trySteer()`,以实现该功能:child 必须处于 `running` 状态,其轮次和步骤在日志中必须仍然打开,该步骤最后一次排空 steering(中途引导)必须尚未开始,且不得已有结构化捕获提交。循环会在排空 steering 并进入 `agent/post-step` 前关闭 `trySteer()` 准入,使终止性 stop 无法丢弃在这个窗口中已确认接收的消息。不提供 `trySteer()` 的循环无法支撑严格的进程内消息投递。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering,因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格操作之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/pre-step` 成功后追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 @@ -59,7 +59,7 @@ durable child Session - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 -- 如果活跃提供方无法接收在线消息、严格 steering 在与 Task 结算的竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 +- 如果活跃提供方无法接收在线消息、带确认语义的 steering 在准入竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 @@ -73,7 +73,7 @@ durable child Session 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 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?()`,以免其严格的仅在线契约与服务编排或面向模型的工具混淆。 +从持久化存储恢复不能依赖旧 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。`SubagentRun` 不含 `resume` 操作:run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。原有的 `SubagentRun.sendMessage?()` 功能改名为 `SubagentRun.steer?()`,以免其提供确认语义且仅适用于在线消息的契约与服务编排或面向模型的工具混淆。 `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 @@ -97,7 +97,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。 -**拆分 `send_message` 与 `follow_up`。** 两个严格操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 +**拆分 `send_message` 与 `follow_up`。** 两个独立的投递操作会向模型暴露实现状态差异,却无法消除 child 已停止时的竞态。单一操作采用 Claude Code 模型:向运行中的工作发送消息,或恢复一个由新 Task 支撑的生命周期。 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 47486ee35f..d7d4618aac 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: c5788ad33dc87e104dbdf0f420ac937af9ff2662 -architecture.zh.md: db98ead01d5bcb689a2cfd199eaae059763ad19e +architecture.md: d7beb60baac3c550eb008d414158d9a05181337a +architecture.zh.md: 200f82df3d936b45f4aeef0cb080c55483af602a diff --git a/docs/architecture.md b/docs/architecture.md index c5788ad33d..d7beb60baa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,12 +93,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' - open strict-steering acceptance + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -107,10 +107,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - close strict-steering acceptance, then drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -122,7 +122,7 @@ idle inject: Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. The default loop closes its optional `trySteer()` acceptance immediately before the final steering drain; ordinary `steer()` keeps its best-effort routing semantics. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -136,7 +136,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, optional `trySteer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. The default loop's `trySteer()` atomically rejects after the current step's final steering drain begins, while ordinary `steer()` retains best-effort routing. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. +`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index db98ead01d..200f82df3d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -93,12 +93,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' - open strict-steering acceptance + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -107,10 +107,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - close strict-steering acceptance, then drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -122,7 +122,7 @@ idle inject: 每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。默认循环会在最后一次排空 steering 前立即关闭其可选 `trySteer()` 的准入;普通 `steer()` 保留尽力路由语义。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -136,7 +136,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()`、可选的 `trySteer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。当前步骤开始最后一次排空 steering 后,默认循环的 `trySteer()` 会原子地拒绝调用,而普通 `steer()` 保留尽力路由语义。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 +`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()` 或 `followup()`、带回执的 `steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()` 与 `whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f118c5ab44..724867f0d8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:402`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:421`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 76ee56bfb3..43965ad9a3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1970,7 +1970,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * Deliver one message to 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. A throw means the message was NOT delivered — in + * 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 @@ -1984,7 +1984,7 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ -sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult +async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): 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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 91c1f1fabb..795256a2b3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -560,6 +560,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` always resolves. `admitted` identifies the turn and step whose immutable request history contains that exact message; `rejected` means lifecycle or terminal policy discarded it first. Synchronous input validation still throws from `steer()`. + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -661,26 +663,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void - - /** - * Atomically submit steering only while the current step still owns its final - * drain. Returns `false` without accepting the message during admission, - * between steps, or after the final per-step drain has begun. Cancellation or - * disposal may still discard previously accepted steering. - * @param message - identified steering content and its producer provenance. - * @returns whether the message entered the current step. - */ - trySteer?(message: UserMessage): boolean + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index b746073424..acd0dea472 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -10,7 +10,7 @@ Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/sub ## 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: strict 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 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). ```ts type-equiv /** @@ -18,7 +18,7 @@ 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 are optional methods whose presence is the capability — strict live steering + * 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. @@ -214,7 +214,7 @@ interface SubagentStopReasonMap { ## A live 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 strict `steer` method advertises live delivery by presence. 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 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. ```ts type-equiv /** @@ -251,19 +251,16 @@ interface SubagentRun { */ dispose(): Promise /** - * 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 verify, with no - * asynchronous boundary before delivery, that the child is running and its - * turn can still record the message, and must not fall back to a queue path - * that could start a new, untracked turn or silently drop the message after - * this run has settled. Throws when delivery cannot join the turn. A run - * represents one disposable activation, so it has no cold-resume operation; - * resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the child's logged steering message without - * changing its user role in model history. + * 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): void + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b3e8ff6f2..7fc4f549f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `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:343`](../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:274`](../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:283`](../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:457`](../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:321`](../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:333`](../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:302`](../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:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../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:396`](../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:415`](../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:356`](../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) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `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/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/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) | +| `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) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 5c484484bb..34193d60d1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n 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 };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n 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 };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 022232cb28..2d126ff5f9 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -44,7 +44,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index acdb7da8f7..1d94399184 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,7 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), updateInbox: () => 'not-found', inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index bedffbf90a..158d96f24d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -179,7 +179,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { status: 'idle', acceptsNextStep: false, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 749ba48359..43818cd6b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -889,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', }, { - signature: 'sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. A throw means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', + jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', }, ], }, @@ -1583,7 +1583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n trySteer?(message: UserMessage): boolean;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', @@ -2669,6 +2669,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'SteeringOutcome', + declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};', + }, + { + name: 'SteeringReceipt', + declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise;\n}', + }, { name: 'StorageForms', declaration: 'export interface StorageForms {\n}', @@ -2703,7 +2711,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): void;\n}', + 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}', }, { name: 'SubagentStartRequest', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 71cf5b0ec8..6e9e63a22f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c -README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3 +README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 +README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79d2865073..1662b1076c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -57,6 +57,8 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it. + Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 48c4f4900d..2fca32a02f 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -57,6 +57,8 @@ interface Config { 统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。 + 每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 98c0fae6bc..b41dd69594 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -29,6 +29,8 @@ import type { RequestError, RequestErrorAction, SendOptions, + SteeringOutcome, + SteeringReceipt, } from '@deepseek-ai/dsh-agent' import { BlockAssembler, @@ -56,6 +58,26 @@ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +/** Internal one-shot controller paired with a public steering receipt. */ +interface SteeringDelivery { + readonly receipt: SteeringReceipt + settle(outcome: SteeringOutcome): void +} + +/** Create one idempotent steering-admission controller. */ +function createSteeringDelivery(): SteeringDelivery { + const { promise, resolve } = Promise.withResolvers() + let settled = false + return { + receipt: { outcome: promise }, + settle(outcome): void { + if (settled) return + settled = true + resolve(outcome) + }, + } +} + const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt' /** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */ const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' @@ -112,9 +134,13 @@ function requestProposal(header: EpochHeader): LlmCallConfig { */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { item: InboxItem; wakeup: boolean }[] = [] + private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = [] + private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = [] + /** Steering already committed to the log but not yet captured by a request. */ + private pendingAdmissions: SteeringDelivery[] = [] + /** Whether the active cancellation preserves already committed pending delivery. */ + private preservePendingAdmissionsOnAbort = false /** Whether observers see a running interval; consecutive turns share it. */ private busy = false @@ -142,8 +168,6 @@ export class ReactLoopAgent implements Agent { /** Whether the session log is owed a matching turn end event. */ private turnOpen = false private stepOpen = false - /** Whether {@link trySteer} can still join the current step's final drain. */ - private strictSteeringOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false @@ -167,6 +191,15 @@ export class ReactLoopAgent implements Agent { send( message: UserMessage, options: SendOptions, + ): void { + this.route(message, options) + } + + /** Route one accepted message, optionally tracking steering admission. */ + private route( + message: UserMessage, + options: SendOptions, + delivery?: SteeringDelivery, ): void { const { target, wakeup } = options if (target === 'next-step' && !wakeup) { @@ -185,9 +218,9 @@ export class ReactLoopAgent implements Agent { placement, }) if (placement === 'steering') { - this.outbox.push({ message, steering: true, item }) + this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } }) } else { - this.queued.push({ item, wakeup }) + this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers @@ -218,6 +251,7 @@ export class ReactLoopAgent implements Agent { } case 'remove': { this.queued.splice(queuedIndex, 1) + pending.delivery?.settle({ status: 'rejected' }) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) return 'applied' } @@ -235,22 +269,14 @@ export class ReactLoopAgent implements Agent { }) } - /** Steer the open turn, falling back to a waking prompt while idle. */ - steer(input: UserMessage): void { - this.send(input, { + /** Steer the open turn, falling back to a tracked waking prompt while idle. */ + steer(input: UserMessage): SteeringReceipt { + const delivery = createSteeringDelivery() + this.route(input, { target: 'next-step', wakeup: true, - }) - } - - /** Atomically steer only while the current step still owns its final drain. */ - trySteer(input: UserMessage): boolean { - if (!this.strictSteeringOpen) return false - this.send(input, { - target: 'next-step', - wakeup: true, - }) - return true + }, delivery) + return delivery.receipt } /** Append model-facing context without waking the driver. */ @@ -304,11 +330,17 @@ export class ReactLoopAgent implements Agent { // inboxes clear; listener failures are contained by the dispatcher. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } + if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true if (!options.keepInbox) { const discarded = this.queued.map(item => item.item) + for (const item of this.queued) item.delivery?.settle({ status: 'rejected' }) for (const item of this.outbox) { - if (item.steering && item.item !== undefined) discarded.push(item.item) + if (item.steering && item.item !== undefined) { + item.delivery?.settle({ status: 'rejected' }) + discarded.push(item.item) + } } + this.rejectPendingAdmissions() // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 this.outbox.length = 0 @@ -373,7 +405,8 @@ export class ReactLoopAgent implements Agent { // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // oxlint-disable-next-line typescript/no-non-null-assertion - const { item } = this.queued.shift()! + const pending = this.queued.shift()! + const { item, delivery } = pending const { message } = item const inheritedOutboxLength = this.outbox.length @@ -423,6 +456,7 @@ export class ReactLoopAgent implements Agent { // still owns the slot here and releasing it unconditionally is exact. this.abort = undefined if (admitted === undefined) { + delivery?.settle({ status: 'rejected' }) this.acceptsNextStep = false try { this.flushRejectedAdmissionContexts() @@ -440,7 +474,7 @@ export class ReactLoopAgent implements Agent { this.continueOrIdle() return } - await this.run(trigger, admitted, inheritedOutboxLength) + await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery) }) // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation @@ -457,6 +491,7 @@ export class ReactLoopAgent implements Agent { admitted: UserMessage[] = [], inheritedOutboxLength = 0, priorFailures: readonly LlmFailure[] = Object.freeze([]), + promptDelivery?: SteeringDelivery, ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and a retry is entered only after the prior run clears it. @@ -464,6 +499,7 @@ export class ReactLoopAgent implements Agent { if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) const controller = new AbortController() this.abort = controller + this.preservePendingAdmissionsOnAbort = false this.acceptsNextStep = true const signal = controller.signal const turn = this.lastTurn + 1 @@ -487,13 +523,12 @@ export class ReactLoopAgent implements Agent { // Context or steering retained by an earlier rejected admission happened // before this prompt and must occupy the same order in durable history. this.drainOutbox(turn, inheritedOutboxLength) + if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery) for (const input of admitted) { this.session.append('user/message', input, { surfaceOp: 'append' }) } signal.throwIfAborted() - this.drainOutbox(turn) - steps: while (true) { step += 1 const outcome = await this.step(turn, step, signal) @@ -501,17 +536,19 @@ export class ReactLoopAgent implements Agent { case 'completed': requestFailureHistory = Object.freeze([]) if (outcome.maxTokens) reason = { kind: 'max-tokens' } - // A concluding tool result is terminal: steering already in the - // log waits for the next turn's request instead of reopening this - // one, and the agent/turn-stopping drain below is skipped for the same - // reason. - if (outcome.concluded) break steps + // A concluding tool result is terminal: reject steering that did + // not enter a request, while retaining same-boundary context in + // durable history before the turn closes. + if (outcome.concluded) { + this.discardOutboxSteering() + this.drainOutbox(turn) + break steps + } if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue break case 'request-failed': { // step() reports request failures only after step/start commits // and before its own step/end, so the step is always open here. - this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) if (!signal.aborted) { @@ -542,12 +579,14 @@ export class ReactLoopAgent implements Agent { } await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() - if (!this.drainOutbox(turn)) break + this.drainOutboxContexts() + if (!this.outbox.some(item => item.steering)) { + break + } } } catch (caught: unknown) { try { if (this.stepOpen) { - this.strictSteeringOpen = false this.stepOpen = false this.session.append('step/end', { turn, step }) } @@ -565,7 +604,6 @@ export class ReactLoopAgent implements Agent { // failure paths (step(), the request-failed branch, the catch), so the // finally owes only the turn boundary. this.acceptsNextStep = false - this.strictSteeringOpen = false try { if (this.turnOpen) { // Re-entrant turn/end listeners must route new input to a later turn. @@ -582,6 +620,10 @@ export class ReactLoopAgent implements Agent { // is still this run's controller here. this.abort = undefined signal.removeEventListener('abort', cancelRetry) + const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort + this.preservePendingAdmissionsOnAbort = false + // oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited. + if (!preservePending) this.rejectPendingAdmissions() } if (opened) { @@ -620,10 +662,6 @@ export class ReactLoopAgent implements Agent { await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) signal.throwIfAborted() - // Take the outbox whole — same-boundary steering and context leave in - // this request together. - this.drainOutbox(turn) - // Assemble request-owned prompt inputs fresh each step. Dynamic context is // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. @@ -632,13 +670,18 @@ export class ReactLoopAgent implements Agent { const system = renderPrompt(assembly) materializeRuntimeContext(session, renderContextSnapshot(assembly)) + // Commit the exact pending batch only after every asynchronous + // pre-request contribution succeeded. Input accepted after this splice + // remains pending for a later request. + this.drainOutbox(turn) + // Snapshot the exact log prefix: the reconstruction boundary. Appends // after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) this.stepOpen = true - this.strictSteeringOpen = true + this.admitPendingAdmissions(turn, step) signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( @@ -705,15 +748,14 @@ export class ReactLoopAgent implements Agent { )) } - // Tool results stay adjacent to their calls; input accepted during the - // request enters the log only after the complete result batch. - this.strictSteeringOpen = false - const steered = this.drainOutbox(turn) + // Ordinary context keeps the base loop's result-adjacent commit point. + // Steering remains provisional until the next request snapshot admits it. + this.drainOutboxContexts() session.append('step/end', { turn, step }) this.stepOpen = false return { kind: 'completed', - continueTurn: (toolCalls.length > 0 && !concluded) || steered, + continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering), concluded, maxTokens: finish.kind === 'max-tokens', } @@ -818,25 +860,83 @@ export class ReactLoopAgent implements Agent { return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - /** Commit the outbox and report whether it contained steering. */ - private drainOutbox(turn: number, limit = this.outbox.length): boolean { - let steered = false - for (const item of this.outbox.splice(0, limit)) { - if (item.steering) { - steered = true - /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ - if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) - this.session.append( - 'steering/message', - { turn, message: item.message }, - { surfaceOp: 'append' }, - ) - } else { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) + /** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */ + private drainOutbox(turn: number, limit = this.outbox.length): void { + const batch = this.outbox.splice(0, limit) + for (let index = 0; index < batch.length; index += 1) { + const item = batch[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`) + try { + if (item.steering) { + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) + this.session.append( + 'steering/message', + { turn, message: item.message }, + { surfaceOp: 'append' }, + ) + if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery) + } else { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } + } catch (error: unknown) { + item.delivery?.settle({ status: 'rejected' }) + this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index)) + throw error } } - return steered + } + + /** Commit ordinary context while retaining provisional steering in order. */ + private drainOutboxContexts(): void { + const pending = this.outbox + this.outbox = [] + for (let index = 0; index < pending.length; index += 1) { + const item = pending[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`) + if (item.steering) { + this.outbox.push(item) + continue + } + try { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } catch (error: unknown) { + this.outbox.push(...pending.slice(index)) + throw error + } + } + } + + /** Settle every committed steering item captured by this immutable request. */ + private admitPendingAdmissions(turn: number, step: number): void { + const outcome: SteeringOutcome = { status: 'admitted', turn, step } + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome) + } + + /** Reject committed steering that left the inbox without reaching a request. */ + private rejectPendingAdmissions(): void { + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' }) + } + + /** Discard uncommitted steering while retaining same-boundary injected context. */ + private discardOutboxSteering(): void { + const contexts: typeof this.outbox = [] + const discarded: InboxItem[] = [] + for (const item of this.outbox) { + if (!item.steering) { + contexts.push(item) + continue + } + item.delivery?.settle({ status: 'rejected' }) + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + discarded.push(item.item) + } + this.outbox = contexts + if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 295117d7d3..dfa0a91098 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -50,10 +50,11 @@ describe('Agent', () => { }])).toBeUndefined() expect(call('inject', [message('context')])).toBeUndefined() expect(call('followup', [message('followup')])).toBeUndefined() - expect(call('steer', [message('steering')])).toBeUndefined() + const receipt = agent.steer(message('steering')) await agent.whenIdle() expect(adapter.requests).toHaveLength(3) + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 }) }) it('idle inject() appends context without opening a turn or requesting a flush', async () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index a362db8552..725e3db36a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -721,13 +721,14 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { // Steering lands while the concluding tool is still executing. - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) + receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] }, @@ -740,9 +741,9 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) const events = agent.session.events.map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) - // The steering is durable inside the concluded turn and feeds the NEXT - // turn's request instead of being dropped or re-queued. - expect(events).toContain('steering/message') + if (receipt === undefined) throw new Error('concluding tool did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(events).not.toContain('steering/message') send(agent, 'follow up') await waitForIdle(ctx, agent) @@ -751,7 +752,7 @@ describe('agent loop', () => { .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) - expect(texts).toContain('late steering') + expect(texts).not.toContain('late steering') }) it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6026c917c6..8a60283521 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -64,8 +64,7 @@ The handle every plugin programs against: - `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. -- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering. +- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. - `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 851c174ba8..ffa71ea987 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -64,7 +64,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, - `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 - `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 -- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 +- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 - `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 - `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1acf8c5ae5..1a1010e90e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -58,6 +58,20 @@ export type InboxAction = /** Result of applying an inbox action at the synchronous ownership boundary. */ export type InboxActionResult = 'applied' | 'not-found' +/** Final admission outcome for one call to {@link Agent.steer}. */ +export type SteeringOutcome = + | { readonly status: 'admitted'; readonly turn: number; readonly step: number } + | { readonly status: 'rejected' } + +/** + * Message-owned steering admission receipt. The outcome promise always + * resolves: synchronous input validation still throws from {@link Agent.steer}, + * while lifecycle policy reports non-admission as `rejected`. + */ +export interface SteeringReceipt { + readonly outcome: Promise +} + /** * Options for the unified {@link Agent.send} primitive over the * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} @@ -225,26 +239,18 @@ export interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void - - /** - * Atomically submit steering only while the current step still owns its final - * drain. Returns `false` without accepting the message during admission, - * between steps, or after the final per-step drain has begun. Cancellation or - * disposal may still discard previously accepted steering. - * @param message - identified steering content and its producer provenance. - * @returns whether the message entered the current step. - */ - trySteer?(message: UserMessage): boolean + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 8d586313b6..09f3af6cff 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -26,7 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, reserveTurnAdmission: () => undefined, cancel() {}, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 797263897f..1800b1c3fd 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -36,7 +36,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1da1e0da48..31d7f5c8d3 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -40,7 +40,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { appendInjection(session, input) }, reserveTurnAdmission: () => undefined, cancel() { status = 'idle' }, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 82793618c4..c7886fac0f 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -50,7 +50,7 @@ function stubAgentForSession(session: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { if (shouldDefer) deferred.push(input) else appendInjection(session, input) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 308395a0a1..7b12d76f59 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -41,7 +41,7 @@ function liveAgent(ctx: Context, session: Session): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input: UserMessage) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index e68b9f73bb..4278fc5a50 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -35,7 +35,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index da05a4cd9b..c59dcf7e64 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -48,7 +48,7 @@ function stubAgent(session: Session): Agent { acceptsNextStep: false, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index f04fff74ba..d9d8aa16d1 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent { options: {}, session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 46c4f1f6c9..6fa7804980 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 08bd39c28a..0b91d5a88c 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -29,7 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index cca0554595..b1bc368ab2 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -46,7 +46,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 2c767de795..10f2b369a2 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d5164261c7..d6b4a08968 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index bef549d483..a25f97ccd0 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ce92c12b39..df8f4e6fed 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -49,7 +49,7 @@ function agentForCwd(cwd: string): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, @@ -70,7 +70,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/subagent/subagent-control/README.md b/packages/subagent/subagent-control/README.md index 141333f719..d1afd86687 100644 --- a/packages/subagent/subagent-control/README.md +++ b/packages/subagent/subagent-control/README.md @@ -6,7 +6,7 @@ The continuable-subagent control service (`ctx.subagentControl`): the one orches A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. -`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's strict `steer` capability and returns the existing Task id (`steered`); an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Failure throws and means the message was not delivered: losing a strict-steering race with Task settlement never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. +`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent-control/src/index.ts index 9de5459451..e8a609e2d6 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent-control/src/index.ts @@ -251,7 +251,7 @@ export class SubagentControlService extends Service { * Deliver one message to 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. A throw means the message was NOT delivered — in + * 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 @@ -265,13 +265,18 @@ export class SubagentControlService extends Service { * @param source - caller-supplied attribution retained across either route. * @returns whether the message `steered` the existing Task or `started` a new one. */ - sendMessage(parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource): SendMessageResult { + async sendMessage( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): Promise { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { return { route: 'steered', - taskId: this.steerActivation(activation, parent, childId, message, source), + taskId: await this.steerActivation(activation, parent, childId, message, source), } } return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } @@ -301,20 +306,20 @@ export class SubagentControlService extends Service { } } - /** Deliver to the running activation's Task through strict live steering. */ - private steerActivation( + /** Deliver to the running activation's Task through confirmed live steering. */ + private async steerActivation( activation: ActiveActivation, parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, - ): TaskId { + ): 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 SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') } - // Owner-session authorization plus the live status for the strict check. + // Owner-session authorization plus the live status for admission. const snapshot = this.ctx.tasks.get(taskId, parent) if (snapshot.status !== 'running') { throw new SubagentControlError( @@ -334,9 +339,9 @@ export class SubagentControlService extends Service { ) } try { - run.steer(message, source) + await run.steer(message, source) } catch (error: unknown) { - // Strict steering lost the race with turn settlement. Deliberately no + // 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 SubagentControlError( diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index c81b88b756..a1c12228d7 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -17,7 +17,7 @@ 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 } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -30,11 +30,14 @@ interface GatedEntry { /** Adapter whose entries can hold a model call open until the test releases it. */ class GatedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + constructor(private script: GatedEntry[]) { super() } async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) const entry = this.script.shift() if (!entry) throw new Error('GatedAdapter: script exhausted') if (entry.gate) await entry.gate @@ -216,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = sendMessage(ctx, parent, started.childId, message('hello?')) + const followUp = await sendMessage(ctx, parent, started.childId, message('hello?')) expect(followUp.route).toBe('started') const failed = await waitTerminal(ctx, followUp.taskId, parent) expect(failed.status).toBe('failed') @@ -264,20 +267,23 @@ describe('SubagentControlService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - expect(() => sendMessage(ctx, parent, started.childId, message('join'))) - .toThrow(/provider does not accept live delivery/) + await expect(sendMessage(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 - try { - sendMessage(ctx, parent, started.childId, message('after terminal')) - } catch (error: unknown) { - terminalDeliveryError = error - } + terminalDelivery = sendMessage(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') }) @@ -310,8 +316,8 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - expect(() => sendMessage(ctx, parent, started.childId, message('join'))) - .toThrow(/registry agent is not the associated activation's agent/) + await expect(sendMessage(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) }) @@ -322,30 +328,32 @@ describe('SubagentControlService.sendMessage', () => { // second step in the SAME turn. let releaseFirst!: () => void const gate = new Promise((resolve) => { releaseFirst = resolve }) - const { ctx, parent } = await setupWith(new GatedAdapter([ + const adapter = new GatedAdapter([ { chunks: textResponse('first step answer'), gate }, { chunks: textResponse('steered turn answer') }, - ])) + ]) + const { ctx, parent } = await setupWith(adapter) const started = ctx.subagentControl.startContinuable(startSpec(parent)) - // Wait for the child agent to publish and enter running. + // Wait until the first immutable request has crossed the adapter boundary. await new Promise((resolve) => { const timer = setInterval(() => { - if (ctx.agents.get(started.childId)?.status === 'running') { + if (adapter.requests.length === 1) { clearInterval(timer) resolve() } }, 5) }) - const delivered = ctx.subagentControl.sendMessage( + const delivery = ctx.subagentControl.sendMessage( parent, started.childId, message('also consider Y'), coordinatorSource, ) - expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) 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. @@ -360,13 +368,57 @@ describe('SubagentControlService.sendMessage', () => { expect(steering?.data.message.source).toEqual(coordinatorSource) }) + it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', 'structured_output', { answer: 7 }), + ]) + 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 base = startSpec(parent) + const started = ctx.subagentControl.startContinuable({ + ...base, + request: { + ...base.request, + outputSchema: { + type: 'object', + properties: { answer: { type: 'number' } }, + required: ['answer'], + }, + }, + }) + await startedTool.promise + + const delivery = ctx.subagentControl.sendMessage( + parent, + started.childId, + message('follow-up that terminal policy rejects'), + coordinatorSource, + ) + 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) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + 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.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = ctx.subagentControl.sendMessage( + const followUp = await ctx.subagentControl.sendMessage( parent, started.childId, message('and then?'), @@ -409,7 +461,7 @@ describe('SubagentControlService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = sendMessage(ctx, parent, started.childId, message('continue')) + const followUp = await sendMessage(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. @@ -438,7 +490,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await sendMessage(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. @@ -454,7 +506,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = sendMessage(ctx, parent, started.childId, message('go on')) + const followUp = await sendMessage(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -474,7 +526,7 @@ describe('SubagentControlService.sendMessage', () => { const started = ctx.subagentControl.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = sendMessage(ctx, parent, started.childId, message('mine now')) + const attempt = await sendMessage(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') @@ -493,7 +545,7 @@ describe('SubagentControlService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) + const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) const snapshot = await waitTerminal(ctx, attempt.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain( @@ -503,9 +555,9 @@ describe('SubagentControlService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) + const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = sendMessage(ctx, parent, SessionId('long-child'), message(longText)) + const long = await sendMessage(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)}…`) @@ -523,14 +575,14 @@ describe('SubagentControlService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .toThrow(SubagentControlError) - expect(() => sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .toThrow(/outside control-service ownership.*not delivered/) + await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(SubagentControlError) + await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(/outside control-service ownership.*not delivered/) await handle.dispose() }) - it('does not fall through to cold resume when strict steering loses the settlement race', async () => { + 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')]) @@ -564,15 +616,15 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - // Strict steering finds the settled child, fails loud, and does NOT start + // Confirmed steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - expect(() => sendMessage(ctx, parent, started.childId, message('too late?'))) - .toThrow(/not delivered/) + await expect(sendMessage(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 = sendMessage(ctx, parent, started.childId, message('retry')) + const retry = await sendMessage(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -581,7 +633,7 @@ describe('SubagentControlService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) const started = ctx.subagentControl.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) - const followUp = sendMessage(ctx, parent, started.childId, message('more')) + const followUp = await sendMessage(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/) }) @@ -600,7 +652,7 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const followUp = sendMessage(ctx, parent, started.childId, message('follow up')) + const followUp = await sendMessage(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) @@ -622,12 +674,12 @@ describe('SubagentControlService.sendMessage', () => { return realLoad(id) } - const first = sendMessage(ctx, parent, started.childId, message('first follow-up')) + const first = await sendMessage(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. - expect(() => sendMessage(ctx, parent, started.childId, message('second follow-up'))) - .toThrow(/not delivered/) + await expect(sendMessage(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') diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 2b5ea80435..7b0ce56b0d 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: eb5d973566f01c05b43f4f56eff746b7af93f60b -README.zh.md: 5be640f9b6da6402ece0e1d15997d9e2970a7d1c +README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888 +README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index eb5d973566..6225b84f12 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, strict steering, and disposal—has one implementation here. +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. ## Start contract @@ -31,7 +31,7 @@ The required request signal covers both startup and the live run. Before publica 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 the strict `steer` capability: the synchronous checks and the `Agent.trySteer()` call share one frame, so delivery joins the observed step or throws. Delivery requires `AgentStatus.running`, an open turn and step in the child log, no committed structured capture, and acceptance before that step's final drain begins. Admission, between-step processing such as `agent/turn-stopping`, and a closed turn's durability flush all reject delivery. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +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 diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 5be640f9b6..5c3ab3baa3 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 定制、结果读取、取消、确认式 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。 ## 启动契约 @@ -31,7 +31,7 @@ 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 -运行公开严格的 `steer` 功能:同步检查与 `Agent.trySteer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的步骤,要么抛错。交付要求 `AgentStatus.running`、子 agent 日志中有开放的轮次和步骤、没有已提交的结构化捕获,并且在该步骤的最终 drain 开始前获接纳。提示词接纳、`agent/turn-stopping` 等步骤间处理,以及已关闭轮次的持久性 flush 都会拒绝交付。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。 +运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。 ## Spawn 与 fork 输入 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f48257ad02..8eb81c05ae 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -238,8 +238,8 @@ export async function resumeInProcessRun(request: SubagentResumeRequest): Promis * 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, strict steering, - * and disposal. + * `boundary`, the continuable-run durability confirmation, confirmed + * steering, and disposal. */ function driveTurn( handle: AgentHandle, @@ -299,46 +299,21 @@ function driveTurn( flags.cancelled = true return handle.dispose() }, - steer(content: ContentBlock[], steeringSource: MessageSource): void { - // Strict live delivery: the synchronous checks and Agent.trySteer() share - // one frame, so delivery joins the observed step or throws. The ordinary - // Agent.steer() idle fallback would instead queue the message and - // start a new, untracked turn after this run's result was read. + 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`) } - // Status stays `running` through the closed turn's durability flush, when - // ordinary steering would queue a later turn. Requiring an open turn - // keeps this activation's acknowledged delivery honest. - const lastBoundary = child.session.events.findLast( - event => event.type === 'turn/start' || event.type === 'turn/end', - ) - if (lastBoundary?.type !== 'turn/start') { - throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) - } - // Between steps there is no current step whose final drain can own strict - // delivery. A message accepted during an open step is recorded at that - // step's settlement checkpoint before the continuation decision - // (cancellation remains the documented shared-outcome race). - const lastStep = child.session.events.findLast( - event => event.type === 'step/start' || event.type === 'step/end', - ) - if (lastStep?.type !== 'step/start') { - throw new Error(`subagent child "${childId}" is between steps; the message was not delivered`) - } - // A committed structured capture makes the pending step conclusion - // terminal. The capture is synchronously observable, so reject rather - // than acknowledge a message the run is about to drop. + // 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`) } - // The atomic Agent operation closes before the final drain, so this - // cannot acknowledge content that the current step will not record. - if (child.trySteer === undefined) { - throw new Error(`subagent child "${childId}" agent does not support strict steering; the message was not delivered`) - } - if (!child.trySteer(createUserMessage({ content, source: steeringSource }))) { - throw new Error(`subagent child "${childId}" passed its steering checkpoint; 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`) } }, } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index a9ef98a892..d3396cd21a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,26 +120,24 @@ describe('in-process structured output', () => { await run.dispose() }) - it('strict steer rejects delivery once the structured result is captured', async () => { + 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 rejected: unknown + let delivery: Promise | undefined ctx.on('session/event', (session, event) => { if (session.header.parentSession === undefined || run === undefined - || event.type !== 'tool/result' || rejected !== undefined) return - try { - run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) - } catch (error: unknown) { - rejected = error - } + || 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 - expect(rejected).toBeInstanceOf(Error) - expect((rejected as Error).message) - .toMatch(/already reported its structured result; the message was not delivered/) + 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() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index b26ca6b84d..18ca3aeb7a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -10,7 +10,8 @@ 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 { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +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' type Script = ConstructorParameters[0] @@ -290,7 +291,7 @@ describe('startInProcessRun', () => { reserveTurnAdmission: () => undefined, updateInbox: () => 'not-found', followup(): void {}, - steer(): void {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } }, inject(): void {}, cancel(): void {}, whenIdle: () => Promise.resolve(), @@ -381,156 +382,103 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) - it('strict steer rejects a settled child instead of queueing an untracked turn', async () => { + 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 - // The child is idle after its turn: Agent.steer() would silently QUEUE. - expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) - .toThrow(/not running; the message was not delivered/) + 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('strict steer rejects the between-steps turn-stopping window', async () => { - // Hold `agent/turn-stopping` open after the step closed and pending - // steering was folded into the continuation decision. - const { ctx, parent } = await setup([textResponse('quick')]) - let releaseStop: (() => void) | undefined - ctx.on('agent/turn-stopping', (agent) => { - if (agent.session.header.parentSession === undefined || releaseStop !== undefined) return undefined - return new Promise((resolve) => { - releaseStop = () => { resolve(undefined) } - }) - }) - const run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseStop !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(child.status).toBe('running') - expect(() => { - run.steer!([{ type: 'text', text: 'too late for this turn' }], { kind: 'user' }) - }) - .toThrow(/between steps; the message was not delivered/) - releaseStop!() - await run.result - expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) - await run.dispose() - }) - - it('strict steer rejects reentrant delivery after the final drain begins', async () => { - const { ctx, parent } = await setup([textResponse('quick')]) - let run: Awaited> | undefined - let seeded = false - let rejected: unknown - ctx.on('session/event', (session, event) => { - if (session.header.parentSession === undefined || run === undefined) return - if (event.type === 'assistant/chunk' && !seeded) { - seeded = true - run.steer?.([{ type: 'text', text: 'accepted before the drain' }], { kind: 'user' }) - } else if (event.type === 'steering/message' && rejected === undefined) { - try { - run.steer?.([{ type: 'text', text: 'after the drain began' }], { kind: 'user' }) - } catch (error: unknown) { - rejected = error - } - } - }) - - run = await startInProcessRun(request(parent), {}) - const child = ctx.agents.get(run.id)! - await run.result - expect(seeded).toBe(true) - expect(rejected).toBeInstanceOf(Error) - expect((rejected as Error).message) - .toMatch(/passed its steering checkpoint; the message was not delivered/) - expect(child.session.events.filter(event => event.type === 'steering/message')).toHaveLength(1) - await run.dispose() - }) - - it('strict steer rejects an Agent implementation without atomic steering', async () => { - const childId = SessionId('custom-loop-child') - const childSession = new Session(childId) - childSession.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - childSession.append('step/start', { turn: 1, step: 1 }) - const idle = Promise.withResolvers() - const child = { - id: childId, - options: {}, - session: childSession, - status: 'running', - acceptsNextStep: false, - ctx: new Context(), - send(): void {}, - reserveTurnAdmission: () => undefined, - updateInbox: () => 'not-found', - followup(): void {}, - steer(): void {}, - inject(): void {}, - cancel(): void {}, - whenIdle: () => idle.promise, - } as Agent - const parentId = SessionId('custom-loop-parent') - const parent = { - id: parentId, - options: {}, - session: new Session(parentId), - ctx: { - get: () => undefined, - agents: { - create: () => Promise.resolve({ - agent: child, - dispose: () => { - idle.resolve(undefined) - return Promise.resolve() - }, - }), - }, + 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' }] }, - } as unknown as Agent - - const run = await startInProcessRun(request(parent), {}) - expect(() => { - run.steer!([{ type: 'text', text: 'unsupported strict delivery' }], { kind: 'user' }) - }) - .toThrow(/does not support strict steering; the message was not delivered/) - await run.dispose() - await run.result - }) - - it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { - // Hold the turn-end durability flush open: the turn has closed in the log - // and status is still `running`, exactly the window where the loop would - // discard a drained steering message instead of recording it. - const { ctx, parent } = await setup([textResponse('quick')]) - let releaseFlush: (() => void) | undefined - ctx.on('session/flush', (session) => { - if (session.header.parentSession === undefined || releaseFlush !== undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') - if (lastEnd === undefined) return - return new Promise((resolve) => { releaseFlush = resolve }) - }) + })) const run = await startInProcessRun(request(parent), {}) const child = ctx.agents.get(run.id)! - // Wait until the child's turn has closed while the flush keeps it running. - await new Promise((resolve) => { - const timer = setInterval(() => { - if (releaseFlush !== undefined) { clearInterval(timer); resolve() } - }, 5) - }) - expect(child.status).toBe('running') - expect(() => { run.steer!([{ type: 'text', text: 'into the void' }], { kind: 'user' }) }) - .toThrow(/turn has already closed; the message was not delivered/) - releaseFlush!() + 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 8b55dd25f9..aeb6ef8cb7 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -235,7 +235,7 @@ describe('dsh-subagent-spawn', () => { expect(result.stopReason).toBe('aborted') }) - it('exposes strict steer (no run-level resume): a settled child throws instead of queueing', async () => { + it('exposes confirmed steer (no run-level resume): a settled child rejects instead of queueing', 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 @@ -243,11 +243,11 @@ describe('dsh-subagent-spawn', () => { expect('resume' in run).toBe(false) expect(typeof run.steer).toBe('function') await run.result - // Strict live-only contract: after the child settles, delivery fails loud + // 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). - expect(() => { run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' }) }) - .toThrow(/not running; the message was not delivered/) + await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) + .rejects.toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 68003363cd..d08d18e6e3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -45,7 +45,7 @@ Start-time features are advertised in `provider.capabilities` because the servic - `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?` delivers strictly to the actively running child turn (it throws rather than queueing when the child is not running), and `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. +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. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index eb26c79665..e202718f81 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -42,12 +42,12 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。 + ## 委派深度 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering(中途引导),`resume?` 则异步创建延续运行。方法是否存在就是能力检查。 - `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 ## 所有权与生命周期 diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 0806a01554..aa215be03c 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -28,7 +28,7 @@ export function SubagentRunId(id: string): SubagentRunId { * {@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 — strict live steering + * 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. @@ -221,19 +221,16 @@ export interface SubagentRun { */ dispose(): Promise /** - * 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 verify, with no - * asynchronous boundary before delivery, that the child is running and its - * turn can still record the message, and must not fall back to a queue path - * that could start a new, untracked turn or silently drop the message after - * this run has settled. Throws when delivery cannot join the turn. A run - * represents one disposable activation, so it has no cold-resume operation; - * resuming a settled child goes through {@link SubagentProvider.resume}. - * `source` is retained on the child's logged steering message without - * changing its user role in model history. + * 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): void + steer?(content: ContentBlock[], source: MessageSource): Promise } /** 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 927162d21d..93c401007e 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 @@ -101,7 +101,7 @@ describe('dsh-tool-subagent-control', () => { // Reach past the tool into the control service to fake a running route // deterministically: the tool is a thin adapter, so its steered wording is // what this test pins. - ctx.subagentControl.sendMessage = (agent, _childId, message, messageSource) => { + ctx.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 765bdebf86..fb8ac03c23 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: (): 'not-found' => 'not-found', diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 709fb4f181..a62b695b72 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -228,7 +228,7 @@ export async function createTuiTestHarness { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -5553,7 +5553,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -5588,14 +5588,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -5626,7 +5626,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -5670,7 +5670,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 61d7894711..5c88ab3fc2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1775, "docs/AGENTS.md": 1150, - "docs/architecture.md": 2040, + "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, From 43151ed9c015058ad3484520733766ab7f048000 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 14:57:54 +0800 Subject: [PATCH 12/90] fix(subagent): preserve cancellation during durability --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- .../tests/subagent-control.spec.ts | 23 ++++++++++++++ .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 22 ++++++++----- .../tests/subagent-inprocess.spec.ts | 31 +++++++++++++++++++ 9 files changed, 78 insertions(+), 18 deletions(-) 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 5c1d407e1d..c3aacff7e2 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 -2026-07-21-continuable-background-subagents.md: b5683f7e4a81a65b176ff4b4306c1ad0b761cc58 -2026-07-21-continuable-background-subagents.zh.md: 0b0f22d0945bf270267df1698b9145f0ab4b04f1 +2026-07-21-continuable-background-subagents.md: 67388825d93bd4f6f39a11f6deec0aeb42c2ed2a +2026-07-21-continuable-background-subagents.zh.md: 8a8fe6f0abf7998c4d34a21ffb8db57651968fdc 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 b5683f7e4a..67388825d9 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 @@ -77,7 +77,7 @@ Cold resume cannot depend on an optional method of the old `SubagentRun`, becaus `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. +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. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. 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. @@ -107,7 +107,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and 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.ts` drives 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_kill` during a run or during cold-resume lookup settles `killed` after 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/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives 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_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after 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.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_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 through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. 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 0b0f22d094..8a8fe6f0ab 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 @@ -77,7 +77,7 @@ durable child Session `SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 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 写入持久化目录。 @@ -107,7 +107,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent-control/tests/subagent-control.spec.ts index a1c12228d7..9e9e4573e8 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent-control/tests/subagent-control.spec.ts @@ -237,6 +237,29 @@ describe('SubagentControlService.startContinuable', () => { 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 started = ctx.subagentControl.startContinuable(startSpec(parent)) + + await checkpointStarted.promise + expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') + releaseCheckpoint.resolve(undefined) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) }) describe('SubagentControlService.sendMessage', () => { diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 7b0ce56b0d..3cf7ffb480 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: 6225b84f1274b61cae1d4ca567155dcc6e6a0888 -README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92 +README.md: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab +README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 6225b84f12..1bbbfd282f 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 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 one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 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. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend 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. 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 5c3ab3baa3..d6dc91415b 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为。 +5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8eb81c05ae..20c258e195 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -272,11 +272,13 @@ function driveTurn( try { await child.ctx.sessions.flush(child.session) } catch (error: unknown) { - 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 }, - ) + 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( @@ -284,6 +286,7 @@ function driveTurn( boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, + durability === 'required' && signal.aborted, ) } finally { signal.removeEventListener('abort', onAbort) @@ -325,6 +328,7 @@ 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') @@ -332,9 +336,11 @@ 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. A requested cancellation owns - // every non-completed in-flight outcome; a turn already completed stays so. - const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' + // `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 if (structured !== undefined) { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 18ca3aeb7a..50f8b03370 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -111,6 +111,37 @@ describe('startInProcessRun', () => { 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 () => { const { ctx, parent } = await setup([textResponse('driver answer')]) let flushes = 0 From 0a95ad8cc03c1147990206b19e99e9a69b49077e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 24 Jul 2026 16:33:44 +0800 Subject: [PATCH 13/90] fix(sdk): mount task controls for subagents --- ...26-07-21-continuable-background-subagents.i18n.yaml | 6 +++--- .../2026-07-21-continuable-background-subagents.md | 3 ++- .../2026-07-21-continuable-background-subagents.zh.md | 3 ++- packages/sdk/helper/src/features/builtin/index.ts | 2 ++ packages/sdk/helper/tests/project.spec.ts | 10 ++++++++++ 5 files changed, 19 insertions(+), 5 deletions(-) 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 c3aacff7e2..956acaae45 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 @@ -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-21-continuable-background-subagents.md: 67388825d93bd4f6f39a11f6deec0aeb42c2ed2a -2026-07-21-continuable-background-subagents.zh.md: 8a8fe6f0abf7998c4d34a21ffb8db57651968fdc +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +2026-07-21-continuable-background-subagents.md: 2ee8a7ce19bba3f44a5bd58429e323e0eb818d36 +2026-07-21-continuable-background-subagents.zh.md: 53a4797dbafb7c69a45b99646be6bb58b465469b 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 67388825d9..2ee8a7ce19 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 @@ -35,7 +35,7 @@ Every later turn creates another Task. Its producer resources cover only that ac 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](../../proposed/feature/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. +`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. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. 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. @@ -110,6 +110,7 @@ Task records and active-run associations are process-local. Persistence makes th - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives 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_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after 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.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. +- `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. - 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 through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. ## Consequences 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 8a8fe6f0ab..53a4797dba 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 @@ -35,7 +35,7 @@ durable child Session 用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过控制服务,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 @@ -110,6 +110,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 ## 影响 diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 31b4fc77c3..e72bb72c8a 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -212,6 +212,8 @@ config: // The control pair rides every resumable in-process option: background // delegation on spawn/fork is continuable and advertises send_message. baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, + { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 648ec11428..d006465e77 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -204,6 +204,16 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') }) + it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { + const project = await createCommitted([selection('subagent', [option])]) + expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks') + expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') + expect(project.packageManifest().dependencies).toMatchObject({ + '@deepseek-ai/dsh-tasks': '^0.0.1', + '@deepseek-ai/dsh-tool-tasks': '^0.0.1', + }) + }) + it('round-trips embed app projects without a front-door Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) From 88f913a9aeb3e84490e995510fe29696995166ed Mon Sep 17 00:00:00 2001 From: Dudu <3414513905@qq.com> Date: Mon, 27 Jul 2026 00:00:14 +0800 Subject: [PATCH 14/90] refactor(subagent): merge continuation control service --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 10 +- ...-21-continuable-background-subagents.zh.md | 10 +- ...6-merge-subagent-control-service.i18n.yaml | 6 + ...26-07-26-merge-subagent-control-service.md | 37 ++++++ ...07-26-merge-subagent-control-service.zh.md | 37 ++++++ apps/cli/composition.md | 3 - apps/cli/config/base.cordis.yml | 10 +- apps/cli/package.json | 1 - docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 3 +- docs/architecture.zh.md | 3 +- docs/capability-seams.md | 12 +- docs/config-catalog.md | 11 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 60 +++------ docs/core-data-structures/subagent.md | 14 +- docs/event-producer-consumer.md | 8 +- docs/module-graph.md | 40 +++--- docs/tool-catalog.md | 6 +- examples/acp-agent/composition.md | 3 - examples/acp-agent/cordis.yml | 10 +- .../system-prompt.expected.md | 8 +- .../tool-schemas.expected.json | 8 +- .../both-mode-turn/tool-schemas.expected.json | 8 +- .../code-mode-turn/system-prompt.expected.md | 8 +- .../lsp-definition/tool-schemas.expected.json | 8 +- .../pty-tools/tool-schemas.expected.json | 8 +- .../tool-schemas.expected.json | 8 +- .../text-turn/tool-schemas.expected.json | 8 +- .../web-fetch/tool-schemas.expected.json | 8 +- examples/headless-agent/composition.md | 3 - examples/headless-agent/cordis.yml | 10 +- examples/package.json | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 18 +-- .../sdk/helper/src/features/builtin/index.ts | 13 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 9 +- packages/subagent/README.zh.md | 9 +- packages/subagent/subagent-control/README.md | 37 ------ .../subagent/subagent-control/package.json | 55 -------- .../subagent-control/src/invariant.ts | 32 ----- .../subagent/subagent-control/tsconfig.json | 39 ------ packages/subagent/subagent/README.md | 13 +- packages/subagent/subagent/package.json | 12 ++ .../index.ts => subagent/src/continuation.ts} | 54 ++++---- packages/subagent/subagent/src/descriptor.ts | 2 +- packages/subagent/subagent/src/index.ts | 83 ++++++++++-- packages/subagent/subagent/src/types.ts | 8 +- .../tests/continuation.spec.ts} | 103 +++++++-------- .../subagent/subagent/tests/service.spec.ts | 11 ++ packages/subagent/subagent/tsconfig.json | 6 + .../subagent/tool-subagent-control/README.md | 4 +- .../tool-subagent-control/package.json | 5 +- .../tool-subagent-control/src/index.ts | 12 +- .../tool-subagent-control/src/invariant.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 13 +- .../tool-subagent-control/tsconfig.json | 2 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 5 +- packages/subagent/tool-subagent/README.zh.md | 5 +- packages/subagent/tool-subagent/package.json | 2 - packages/subagent/tool-subagent/src/index.ts | 53 ++++---- .../tool-subagent/tests/tool-subagent.spec.ts | 120 +++++++++--------- packages/subagent/tool-subagent/tsconfig.json | 3 - pnpm-lock.yaml | 66 +--------- python/sdk-runtime/package.json | 1 - scripts/gen-doc-graphs.ts | 14 +- scripts/gen-tool-catalog.ts | 8 +- scripts/type-equiv.manifest.json | 2 +- tsconfig.host.json | 1 - 71 files changed, 543 insertions(+), 673 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md create mode 100644 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md delete mode 100644 packages/subagent/subagent-control/README.md delete mode 100644 packages/subagent/subagent-control/package.json delete mode 100644 packages/subagent/subagent-control/src/invariant.ts delete mode 100644 packages/subagent/subagent-control/tsconfig.json rename packages/subagent/{subagent-control/src/index.ts => subagent/src/continuation.ts} (92%) rename packages/subagent/{subagent-control/tests/subagent-control.spec.ts => subagent/tests/continuation.spec.ts} (90%) 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 956acaae45..d9be549a27 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: 2ee8a7ce19bba3f44a5bd58429e323e0eb818d36 -2026-07-21-continuable-background-subagents.zh.md: 53a4797dbafb7c69a45b99646be6bb58b465469b +2026-07-21-continuable-background-subagents.md: 42e9f6dd653dee8df4b8c068e3a33b7c69f8dc4b +2026-07-21-continuable-background-subagents.zh.md: e643e81f40074c3cc0cd6398b3f1d24e0d9bc988 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 2ee8a7ce19..42e9f6dd65 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,6 +4,8 @@ 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). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force; references below to the control service describe the internal continuation manager now exposed through `ctx.subagents`. + ## 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. @@ -101,15 +103,15 @@ Task records and active-run associations are process-local. Persistence makes th **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. +**Put control orchestration on `SubagentService`.** This service-placement alternative was later adopted by the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md), which keeps raw start/resume transport reusable while isolating optional Task and persistence work in an injected internal manager. **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.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives 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_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after 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/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) 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 service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. -- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. - 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 through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. @@ -117,7 +119,7 @@ Task records and active-run associations are process-local. Persistence makes th - 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()`. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.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. 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 53a4797dba..e643e81f40 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,6 +4,8 @@ Status: implemented [English](2026-07-21-continuable-background-subagents.md) | 中文 +本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效;下文所提控制服务,是指现已通过 `ctx.subagents` 公开的内部继续执行管理器。 + ## 问题 subagent 工具将每次委派视为一个独占的 `SubagentRun`:前台调用和后台 Task 收集结果后 dispose(资源释放)该 run。这种所有权关系能够限制存活 child agent(智能体)的数量,并释放其作用域服务、监听器及提供方资源。持久化的 child 会话可能继续存在,但 parent 缺少持久化目录和工具路径,无法发现该 child 并为其启动另一轮次。 @@ -101,15 +103,15 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 -**将控制编排放在 `SubagentService` 上。** 这样一个服务就能查找描述符、关联 Task 并分发提供方,但会迫使不感知集合的提供方 seam 依赖某个消费方的持久化与 Task 策略。单独的控制服务让前台及不使用 Task 的消费方可以复用 start/resume 传输,同时为工具和 UI 提供统一的编排路径。 +**将控制编排放在 `SubagentService` 上。** 后来的[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md)采用了这一服务放置方案;该方案保持底层 start/resume 传输可复用,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 **增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作;steering 会加入运行中的 Task,不创建第二个 Task,并保留调用方来源;cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达,且不改用从持久化存储恢复路径;resume 加载期间竞争的发送只准入一次。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 -- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 - 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 @@ -117,7 +119,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 - 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 - 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 -- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。控制服务会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentControlService.sendMessage()` 提交用户输入。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.sendMessage()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 - 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 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 new file mode 100644 index 0000000000..1068f5f578 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.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/simplification/2026-07-26-merge-subagent-control-service.md +2026-07-26-merge-subagent-control-service.md: a5ce673997502ef6fbd7c66ff4e94e301d4192ba +2026-07-26-merge-subagent-control-service.zh.md: cf867ab444438b7ee62cde68a3d53e83c3d049d1 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 new file mode 100644 index 0000000000..a5ce673997 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -0,0 +1,37 @@ +# Agent Note: Merge subagent control into the subagent service + +Status: implemented + +English | [中文](2026-07-26-merge-subagent-control-service.zh.md) + +## Problem + +Continuable-child orchestration originally lived in a separate `ctx.subagentControl` service above the raw `ctx.subagents` provider seam. That split kept provider dispatch independent of Tasks and persistence, and gave model and human adapters one orchestration contract. In practice the two services described one capability family, every continuable caller needed both, and the provider-bound delegation tool had to infer policy from `provider.resume` and inspect whether the control service and `send_message` tool happened to be loaded. This made sibling plugin presence decide execution semantics and coupled starting continuable work to an optional follow-up surface. + +## Decision + +`SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. + +The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations. + +`startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry. + +Each `@deepseek-ai/dsh-tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`, defaulting to `one-shot`. This configuration is policy; `provider.resume` is only the capability check for configured continuable mode. A resumable provider can therefore still run one-shot background work. The `send_message` tool is an independent adapter: loading or omitting it neither enables nor disables `startContinuable`. + +## Alternatives considered + +**Keep the separate service.** This preserves the strongest dependency separation, but every production continuable path composes both services and the extra public key exposes an architectural distinction callers do not need. The internal manager preserves optional Task and persistence dependencies without a second service. + +**Infer continuable mode from `provider.resume`.** Method presence correctly states cold-resume capability but not deployment policy. It forced every resumable provider into continuable background semantics and made missing sibling plugins a runtime error. Explicit tool configuration separates choice from capability. + +**Register continuation access or inspect the follow-up tool.** A registry could tell the delegation tool whether a continuation surface exists, but starting durable work does not require any follow-up adapter. Such a registry would encode UI composition into execution policy and recreate the sibling dependency under another name. + +**Merge raw and continuable starts into one method.** A flag on `start` would return either a ready run or immediate Task and child identities, weakening a simple ownership boundary. Keeping `startContinuable` is the smaller change and preserves both contracts explicitly. + +## Consequences + +- The service topology has one public key and one package fewer while raw provider dispatch remains usable without Tasks or persistence. +- Continuable mode fails at provider mount when the configured provider lacks `resume`; missing Tasks, Agents, or persistence still fail at the earliest operation that requires them. +- Follow-up delivery remains optional. Deployments may start and collect continuable work through Task tools without exposing `send_message`. +- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` and `resume` callers do not need them. +- Existing continuation races, authorization, durability, cancellation, and settle-then-dispose semantics are unchanged and remain pinned by the migrated `subagent` tests. 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 new file mode 100644 index 0000000000..cf867ab444 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 将 subagent 控制合并到 subagent 服务 + +Status: implemented + +[English](2026-07-26-merge-subagent-control-service.md) | 中文 + +## 问题 + +可继续 child 的编排最初位于原始 `ctx.subagents` 提供方 seam 之上的独立 `ctx.subagentControl` 服务中。该拆分使提供方分发与 Task 和持久化无关,并为模型与人工适配器提供统一的编排契约。实践中,两个服务属于同一组功能,每个可继续调用方都需要二者,而绑定提供方的委派工具必须根据 `provider.resume` 推断策略,并检查控制服务与 `send_message` 工具是否碰巧已加载。如此一来,配套插件是否存在会决定执行语义,并将可继续工作的启动耦合到可选的后续操作接口。 + +## 决策 + +`SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 + +继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。 + +`startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。 + +每个 `@deepseek-ai/dsh-tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`,默认值为 `one-shot`。这项配置表示策略;`provider.resume` 只用于检查所配置的可继续模式是否受提供方支持。因此,可恢复的提供方仍可执行一次性后台工作。`send_message` 工具是独立适配器:加载或省略该工具既不会启用也不会禁用 `startContinuable`。 + +## 已考虑的替代方案 + +**保留独立服务。** 这样能保持最严格的依赖分离,但每条生产环境中的可继续路径都要组合两个服务,而额外的公开键会暴露调用方并不需要的架构差异。内部管理器无需第二个服务,也能保留可选的 Task 和持久化依赖。 + +**根据 `provider.resume` 推断可继续模式。** 方法是否存在可以准确表示从持久化存储恢复的功能,却不能表示部署策略。这会迫使每个可恢复的提供方都采用可继续后台语义,并使配套插件缺失成为运行时错误。显式的工具配置将选择与功能分离。 + +**注册继续执行访问入口,或检查后续操作工具。** 注册表可以告诉委派工具继续执行接口是否存在,但启动具备持久性的工作不需要任何后续操作适配器。这样的注册表会把 UI 组合编码进执行策略,并以另一个名称重新建立插件间依赖关系。 + +**将底层启动与可继续启动合并为一个方法。** `start` 上的标志会使该方法返回就绪的 run,或立即返回 Task 和 child 标识,从而削弱简单的所有权边界。保留 `startContinuable` 改动更小,也能明确保留两项契约。 + +## 影响 + +- 服务拓扑少了一个公开键和一个包,同时底层提供方分发仍可在没有 Task 或持久化时使用。 +- 配置的提供方缺少 `resume` 时,可继续模式会在提供方挂载阶段失败;缺少 Task、Agent 或持久化时,仍会在需要它们的最早操作处失败。 +- 后续消息投递仍为可选功能。部署可以通过 Task 工具启动并收集可继续工作,而不公开 `send_message`。 +- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 和 `resume` 调用方并不需要它们。 +- 现有的继续执行竞态、授权、持久性、取消及先结算再 dispose 的语义均保持不变,并继续由迁移后的 `subagent` 测试固定。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 76844279f6..189a8af496 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,8 +94,6 @@ flowchart LR cfg --> plugin_tui_subagent_spawn plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_tui_subagent_fork - plugin_tui_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_tui_subagent_control plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_tui_tool_subagent_control plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -189,7 +187,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 4982a521a7..891e2b359f 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -257,12 +257,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the control tool registers the one global -# `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -271,12 +267,14 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + backgroundMode: continuable - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4cfdf29725..e9c719dce7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -101,7 +101,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d7d4618aac..2d14dedd68 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: d7beb60baac3c550eb008d414158d9a05181337a -architecture.zh.md: 200f82df3d936b45f4aeef0cb080c55483af602a +architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330 +architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1 diff --git a/docs/architecture.md b/docs/architecture.md index d7beb60baa..44be3b55ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,8 +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 | -| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | continuable-child Task-backed activation and steer-or-resume routing | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing | | `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 200f82df3d..d803bea1ba 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -38,8 +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) | 具名委托提供方 | -| `ctx.subagentControl` | [`subagent/`](../packages/subagent/README.md) | 可继续子 agent 的 Task 化 activation,以及 steer 或恢复路由 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 | | `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 946091c2c7..2681bd728a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -132,12 +132,11 @@ flowchart LR pkg_compact["compact"] svc_compact["ctx.compact
Compaction seam"] pkg_subagent["subagent"] - svc_subagents["ctx.subagents
Subagent provider registry"] + svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_tool_ralph["tool-ralph"] - svc_subagentControl["ctx.subagentControl
Continuable-subagent control service"] pkg_tool_subagent_control["tool-subagent-control"] + pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tasks_local["tasks-local"] @@ -225,7 +224,6 @@ flowchart LR pkg_storage_domain --> svc_storageDomain pkg_storage_json --> svc_storage pkg_storage_sqlite --> svc_storage - pkg_subagent --> svc_subagentControl pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -314,10 +312,9 @@ flowchart LR svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_workspace - svc_subagentControl --> pkg_tool_subagent - svc_subagentControl --> pkg_tool_subagent_control svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subagents --> pkg_tool_subagent_control svc_subprocess --> pkg_bash_local svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local @@ -394,8 +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-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | -| `ctx.subagentControl` | `core` | [`subagent`](../packages/subagent/subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | - | Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages. | +| `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.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 7980ef4136..c2158222ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1893,6 +1893,12 @@ export interface Config { * parameter and reject forced background calls. */ 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. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -1928,7 +1934,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:27`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:25`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -2343,12 +2349,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) -- `@deepseek-ai/dsh-subagent-control` — requires `subagents` · `tasks` · `agents` ([`packages/subagent/subagent-control/src/index.ts`](../packages/subagent/subagent-control/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) -- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagentControl` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 724867f0d8..db6af68970 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -795,7 +795,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:150`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -812,7 +812,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:124`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -827,7 +827,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -849,7 +849,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:141`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 43965ad9a3..b0d07711f2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,56 +1946,30 @@ async closeAll(): Promise Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) -## `ctx.subagentControl` — `SubagentControlService` +## `ctx.subagents` — `SubagentService` -The continuable-subagent orchestration service. Tool schema and UI adapters are consumers of this one contract: parent and human messages route through sendMessage and share one activation result and cancellation boundary, while foreground one-shot delegation keeps calling `ctx.subagents.start()` directly. +Named provider registry with raw and Task-backed continuation operations. ```ts cordis-catalog /** - * 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. + * 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. */ startContinuable(spec: ContinuableStartSpec): ContinuableStart /** - * Deliver one message to 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. - * @param message - the user-role content to deliver. - * @param source - caller-supplied attribution retained across either route. - * @returns whether the message `steered` the existing Task or `started` a new one. + * Deliver a message to a continuable child by steering its live activation + * or cold-resuming a fresh Task-backed activation. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param message - user-role content to deliver. + * @param source - durable caller attribution. + * @returns the existing steered Task or newly started Task. */ -async sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise -``` +sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): 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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) - -Source: [`packages/subagent/subagent-control/src/index.ts:176`](../../packages/subagent/subagent-control/src/index.ts) - -## `ctx.subagents` — `SubagentService` - -Named provider registry and capability-checked start surface. - -```ts cordis-catalog /** * 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 @@ -2032,7 +2006,7 @@ async start(name: string, request: SubagentStartRequest): Promise /** * Resume a persisted continuable child through the named provider's * `resume` capability, with the same run lifecycle observation as - * {@link start}. The caller (the control service) has already loaded the + * {@link start}. The internal continuation manager has already loaded the * child, folded its descriptor, and authorized the parent; this method owns * only capability-checked dispatch. * @param name - the provider recorded in the child's descriptor. @@ -2042,9 +2016,9 @@ async start(name: string, request: SubagentStartRequest): Promise async resume(name: string, request: SubagentResumeRequest): Promise ``` -Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:191`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:206`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index acd0dea472..9864df25bd 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -4,9 +4,9 @@ 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 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). +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). -Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) and [`packages/subagent/subagent-control/src/index.ts`](../../packages/subagent/subagent-control/src/index.ts) +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 @@ -90,7 +90,7 @@ interface SubagentStartRequest { */ readonly persona?: string /** - * Continuable-child intent, resolved by the control service before start. + * Continuable-child intent, resolved by `ctx.subagents` 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 @@ -105,7 +105,7 @@ interface SubagentStartRequest { ## 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. Every sender supplies a `MessageSource`; the model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. Both project to a user-role model message, but the durable source remains distinct for policy and title consumers. +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 resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. 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 `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the optional model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -119,10 +119,10 @@ interface CoordinatorMessageSource { ```ts type-equiv /** * The resolved continuable-child identity and durable composition record a - * control-service caller attaches to a start request. + * continuation caller attaches to a start request. */ interface SubagentContinuation { - /** Control-allocated stable child session id, published verbatim. */ + /** Service-allocated stable child session id, published verbatim. */ readonly sessionId: SessionId /** Snapshotted descriptor persisted in the child log for cold resume. */ readonly descriptor: SubagentDescriptorData @@ -132,7 +132,7 @@ interface SubagentContinuation { ```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, + * continuation manager 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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7fc4f549f2..204bae369d 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:150`](../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:124`](../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:130`](../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:141`](../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:165`](../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:139`](../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:145`](../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:156`](../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/module-graph.md b/docs/module-graph.md index d880453727..3e6889c80e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,7 +65,6 @@ flowchart TD subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] pkg_subagent_acp["subagent-acp"] - pkg_subagent_control["subagent-control"] pkg_subagent_dsh_sdk["subagent-dsh-sdk"] pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] @@ -740,6 +739,8 @@ flowchart TD pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm @@ -892,13 +893,6 @@ flowchart TD pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subprocess - pkg_subagent_control --> pkg_agent - pkg_subagent_control --> pkg_invariants - pkg_subagent_control --> pkg_llm - pkg_subagent_control --> pkg_session - pkg_subagent_control --> pkg_session_persistence - pkg_subagent_control --> pkg_subagent - pkg_subagent_control --> pkg_tasks pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -908,6 +902,17 @@ flowchart TD pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools pkg_subagent_inprocess --> pkg_user_approval + pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_invariants + pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_subagent + pkg_tool_subagent --> pkg_tasks + pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client pkg_repository_plugin --> pkg_paths @@ -1014,18 +1019,6 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess - pkg_tool_subagent --> pkg_agent - pkg_tool_subagent --> pkg_invariants - pkg_tool_subagent --> pkg_llm - pkg_tool_subagent --> pkg_subagent - pkg_tool_subagent --> pkg_subagent_control - pkg_tool_subagent --> pkg_tasks - pkg_tool_subagent --> pkg_tools - pkg_tool_subagent_control --> pkg_invariants - pkg_tool_subagent_control --> pkg_llm - pkg_tool_subagent_control --> pkg_session - pkg_tool_subagent_control --> pkg_subagent_control - pkg_tool_subagent_control --> pkg_tools pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_invariants pkg_jsonrpc --> pkg_llm @@ -1199,7 +1192,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1226,8 +1219,9 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`subagent-control`](../packages/subagent/subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | @@ -1240,8 +1234,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-control`](../packages/subagent/subagent-control), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent-control`](../packages/subagent/subagent-control), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 0a316bc636..87e9a2a526 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -31,7 +31,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagentControl` | `tool/call`, `tool/result`, `child session events through the control service` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | +| `@deepseek-ai/dsh-tool-subagent-control` | `send_message` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -1117,7 +1117,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent` -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 return a task id; collect with `task_output` and stop with `task_kill`. ```json { @@ -1133,7 +1133,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "run_in_background": { "type": "boolean", - "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message." + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 22adf09555..9524184414 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -39,8 +39,6 @@ flowchart LR cfg --> plugin_acp_subagent_spawn plugin_acp_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_acp_subagent_fork - plugin_acp_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_acp_subagent_control plugin_acp_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_acp_tool_subagent_control plugin_acp_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -83,7 +81,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e45c56c92d..76cac9cbb8 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -96,12 +96,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the separately loaded control tool registers the -# one global `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -110,6 +106,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -117,6 +114,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 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 7df756ee99..e386a25eff 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`. */ 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, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`. */ 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, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ 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 5f8e31fe9b..00ae670288 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 1a7e813d7c..6d052ead19 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 31e8cdce23..15b5e8dde6 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`. */ 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, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`. */ 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, stop with task_kill, follow up with send_message. */ + /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */ 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 517c9b1d71..6124557f08 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 abc3e13256..d4c004034f 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 2ac976d621..72c6b74b72 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 47439bfdb0..bf0103bbeb 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 d1a60f6f92..d143e9d82a 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 @@ -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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages 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 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`.", "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, stop with task_kill, follow up with send_message." + "description": "Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill." } }, "required": [ diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index ecf343a264..0a3da81e04 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,8 +37,6 @@ flowchart LR cfg --> plugin_headless_subagent_spawn plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_headless_subagent_fork - plugin_headless_subagent_control["subagent-control
@deepseek-ai/dsh-subagent-control"] - cfg --> plugin_headless_subagent_control plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_headless_tool_subagent_control plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] @@ -74,7 +72,6 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `subagent-control` | `@deepseek-ai/dsh-subagent-control` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 73673aee8d..4caf63672f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -86,12 +86,8 @@ config: providerName: fork -# Continuable background children: the control service owns durable child ids -# and Task-backed activations; the control tool registers the one global -# `send_message` shared by both delegation tools. -- id: subagent-control - name: '@deepseek-ai/dsh-subagent-control' - +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -100,6 +96,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -107,6 +104,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/examples/package.json b/examples/package.json index fb105d2ad6..bcbb4e21f0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-control": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 43818cd6b7..19f7707262 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -881,23 +881,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ ], }, { - key: 'subagentControl', - summary: 'The continuable-subagent orchestration service.', + key: 'subagents', + summary: 'Named provider registry with raw and Task-backed continuation operations.', methods: [ { signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', - jsDoc: '/**\n * Start a continuable background child: allocate its stable session id,\n * snapshot its durable descriptor, and register the initial activation\'s\n * Task. A synchronous validation failure (a non-JSON descriptor input,\n * missing persistence, Task preflight) throws without creating a Task; the\n * method otherwise returns both identities immediately, without waiting for\n * child publication or descriptor durability. Asynchronous startup failure\n * settles the returned Task as `failed` (or `killed` when cancelled) after\n * any published run is disposed, which can leave an unmaterialized child id\n * that later by-id operations report as unavailable.\n * @param spec - provider, Task label, and the delegation request.\n * @returns the stable child id and the initial activation\'s Task id.\n */', + 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 sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', - jsDoc: '/**\n * Deliver one message to a known continuable child: steer its running\n * activation, or cold-resume the durable session into a fresh Task-backed\n * activation. The two routes are reported distinctly so timing-dependent\n * routing is observable. Rejection means the message was NOT delivered — in\n * particular, losing a race with Task settlement does not fall through to\n * cold resume within the same call; a later retry after Task terminal may\n * start the next activation. The started Task owns descriptor lookup and\n * direct-parent authorization (its AbortSignal exists before that lookup),\n * so an unknown, foreign, or descriptor-less child settles the started Task\n * as `failed` with a detail reporting the id as unavailable.\n * @param parent - the live parent agent sending the message (model tool or\n * human adapter); Task access is authorized by its session id.\n * @param childId - the stable child session id.\n * @param message - the user-role content to deliver.\n * @param source - caller-supplied attribution retained across either route.\n * @returns whether the message `steered` the existing Task or `started` a new one.\n */', + signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', + jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @returns the existing steered Task or newly started Task.\n */', }, - ], - }, - { - key: 'subagents', - summary: 'Named provider registry and capability-checked start surface.', - methods: [ { 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 */', @@ -916,7 +910,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', - jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The caller (the control service) has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', + jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The internal continuation manager has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', }, ], }, diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index e72bb72c8a..139eae3bde 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -209,13 +209,12 @@ config: id: 'subagent', summary: 'Delegate work to child agents', mode: 'multiple', - // The control pair rides every resumable in-process option: background - // delegation on spawn/fork is continuable and advertises send_message. + // In-process options select continuable background delegation; the + // follow-up adapter remains an independently loadable global tool. baseResources: [ { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, - { kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, ], options: [ @@ -229,7 +228,7 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'spawn' } satisfies ToolSubagentConfig, + config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig, }, ], }, @@ -242,7 +241,11 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent-fork', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'fork', toolName: 'subagent_fork' } satisfies ToolSubagentConfig, + config: { + provider: 'fork', + toolName: 'subagent_fork', + backgroundMode: 'continuable', + } satisfies ToolSubagentConfig, }, ], }, diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 0491b589c6..bead24d34c 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: 438907ea7de41842f900b050385f15feac7cc272 -README.zh.md: 87911216bc4e6b5f75e17ca2c58818725f66e7ec +README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46 +README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 438907ea7d..a195ecbaeb 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,16 +6,15 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary + the durable child descriptor | `ctx.subagents` | +| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation 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`) | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) | | `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) | -| `subagent-control/` | Continuable-child orchestration: stable ids, descriptor lookup, Task-backed activation, steer-or-resume routing | `ctx.subagentControl` | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -| `tool-subagent-control/` | The one globally named `send_message` follow-up tool over `ctx.subagentControl` | (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 lives at `subagent/subagent/`. 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). `subagent-control` sits above the seam: it binds one durable child session to a series of disposable Task-backed activations, and both model tools and human-facing adapters route through its one contract. Tests replace only the child boundary with package-local fixtures. +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 proposals and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md) and [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md). +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 87911216bc..b9965030a3 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,16 +6,15 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | 抽象 subagent seam:具名提供方注册表、词汇与持久化子 agent 描述符 | `ctx.subagents` | +| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) | | `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) | | `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) | -| `subagent-control/` | 可继续子 agent 编排:稳定 ID、描述符查找、由 Task 支撑的 activation,以及 steer 或恢复路由 | `ctx.subagentControl` | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -| `tool-subagent-control/` | 基于 `ctx.subagentControl`、全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | +| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。`subagent-control` 位于该 seam 之上:它把一个持久化子会话绑定到一系列可 dispose、由 Task 支撑的 activation,模型工具和面向人的适配器都通过这份统一契约进行路由。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `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 边界。 -提案与设计理由见 [.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/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-control/README.md b/packages/subagent/subagent-control/README.md deleted file mode 100644 index d1afd86687..0000000000 --- a/packages/subagent/subagent-control/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# @deepseek-ai/dsh-subagent-control - -The continuable-subagent control service (`ctx.subagentControl`): the one orchestration path that binds a durable child session to a series of disposable Task-backed activations. Model tools and human-facing adapters call the same contract; the low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic. - -## Activation lifecycle - -A continuable background subagent is a durable child session with a series of Task-backed activations. `startContinuable()` allocates the stable child session id before Task creation, snapshots the descriptor inputs (a non-JSON input throws with no Task), and registers the initial activation's Task; the provider publishes exactly that child id and appends the versioned `subagent/descriptor` event inside the child's first turn. Every activation — initial or resumed — creates a fresh Task whose settlement awaits the provider's durability-confirmed child result, disposes the run, and only then records the `TaskOutcome`: a terminal Task leaves the durable child session but no live child Agent. A provider rejection with `DURABILITY_FAILED` settles the Task as `failed` and copies the error message into `detail`, so `task_output` reports the failed checkpoint and resumability risk without exposing unconfirmed output. - -`sendMessage(parent, childId, message, source)` owns steer-or-resume routing and requires the caller's `MessageSource`. A running activation preserves it through the run's confirmed `steer` capability and returns the existing Task id (`steered`) only after a committed request snapshot admits the message; an absent activation starts a fresh Task that loads the persisted child, authorizes the recorded `parentSession` as the direct parent, folds the descriptor, and dispatches `SubagentService.resume()` with the same source (`started`). Either route projects the content to the model as a user-role message while retaining its source in the child log. Rejection means the message was not delivered: terminal policy or Task settlement winning the admission race never falls through to cold resume within the same call, and a live registry Agent outside the activation association is an ownership conflict rather than an adoption target. - -Cancellation targets the whole activation. `task_kill` or owner disposal aborts the Task-owned signal; before publication the provider rejects only after its creation transaction rolled back to quiescence, afterwards the signal cancels the published run, and settlement records `killed` only once the activation is quiescent. Human input shares this path: an adapter submits child input through `sendMessage()` under the loaded parent, so parent and human messages that joined one turn share its result and cancellation outcome, and `TaskService.start()`'s control-surface requirement applies (load `@deepseek-ai/dsh-tool-tasks` or attach a surface). - -The activation association is process-local routing state, installed before any persistence or provider await and removed after run disposal and Task terminal publication. It is not a durable catalog: restart recovers the child session, not in-flight Tasks or their notifications. - -## Model Experience - -### Task completion and output - -#### What the model sees - -None directly, as this package registers no tool and no prompt text; the model observes continuable children through `@deepseek-ai/dsh-tool-subagent`'s background acknowledgement, `@deepseek-ai/dsh-tool-subagent-control`'s `send_message` results, and the generic task surface, whose outputs this service produces. - -#### Token effect - -None beyond the consuming tools' own results. - -#### KV Cache effect - -None; this service appends nothing to any model-visible sequence. - -## Known Limitations and Deferred Work - -- **Concurrent stopped-child admission is not atomic across awaits** — the synchronous association install admits one activation per child in this process, but a caller bypassing the control service can still race it; the Agent registry's same-id collision is the final backstop, and the losing Task fails with its message not delivered. -- **The association coordinates only one runtime** — concurrent resume from multiple processes needs a persistence-level lease or compare-and-set, which no backend offers yet. -- **Task records are process-local** — restart recovers the durable child session, not an interrupted Task, its result, or its completion notice; durable Task recovery is a separate concern. -- **Human interaction requires the exact live parent Agent** — Task access is fenced by the owner session and owner disposal cancels its Tasks; standalone child conversations belong to the interactive-side-sessions proposal, not this Task-owned lifecycle. -- **ACP children remain one-shot** — `AcpProvider.resume` and per-child continuation advertisement are deferred until the remote-session descriptor contract is resolved. diff --git a/packages/subagent/subagent-control/package.json b/packages/subagent/subagent-control/package.json deleted file mode 100644 index 9522bca29e..0000000000 --- a/packages/subagent/subagent-control/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-control", - "description": "Continuable-subagent control service: Task-backed activation, durable child descriptors, and steer-or-resume message routing", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-tasks": "workspace:^", - "@deepseek-ai/dsh-tasks-local": "workspace:^", - "@deepseek-ai/dsh-tool-tasks": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/subagent/subagent-control/src/invariant.ts b/packages/subagent/subagent-control/src/invariant.ts deleted file mode 100644 index ce40f360ca..0000000000 --- a/packages/subagent/subagent-control/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-control`. - * @module @deepseek-ai/dsh-subagent-control/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-control' - -/** Cordis companion plugin name. */ -export const name = 'subagent-control-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the activation association is deliberately private - * process-local routing state with no event stream of its own; the run - * lifecycle pair it participates in is checked by `@deepseek-ai/dsh-subagent`, - * and Task lifecycle relations belong to `@deepseek-ai/dsh-tasks`. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-control/tsconfig.json b/packages/subagent/subagent-control/tsconfig.json deleted file mode 100644 index d41aacf4fb..0000000000 --- a/packages/subagent/subagent-control/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session-persistence/session-persistence" - }, - { - "path": "../subagent" - }, - { - "path": "../../tasks/tasks" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index d08d18e6e3..af8a2f7b71 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -10,11 +10,10 @@ The family separates the stable interface from implementations and model-facing | Package | Role | |---|---| -| `@deepseek-ai/dsh-subagent` | Provider registry, request/result/descriptor types, and lifecycle events. | +| `@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-acp` | Fresh out-of-process ACP child (one-shot). | -| `@deepseek-ai/dsh-subagent-control` | Continuable-child orchestration: durable ids, descriptors, Task-backed activation. | | `@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. | @@ -22,7 +21,7 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has five main operations: +`SubagentService` has seven main operations: | Member | Meaning | |---|---| @@ -30,7 +29,9 @@ Multiple providers may coexist under different names. This lets a deployment exp | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | | `start(name, request)` | Validate requested capabilities and semantic values, 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. | -| `resume(name, request)` | Capability-checked dispatch to `provider.resume?()` with the same run lifecycle observation as `start`. The caller (the control service) has already loaded the child, folded its descriptor, and authorized the parent; this seam stays collection-, Task-, and persistence-agnostic. | +| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | +| `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. | +| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | `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, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. @@ -63,7 +64,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the `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. -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 control-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. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. The service emits `subagent/start` only after `start()` or `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. @@ -73,7 +74,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; `@deepseek-ai/dsh-subagent-control` registers each activation with the generic `ctx.tasks` runtime, then 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 [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. 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. ## Model Experience diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 58b51d1888..5f9f617eae 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -33,9 +33,19 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-tasks": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", @@ -43,6 +53,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-control/src/index.ts b/packages/subagent/subagent/src/continuation.ts similarity index 92% rename from packages/subagent/subagent-control/src/index.ts rename to packages/subagent/subagent/src/continuation.ts index e8a609e2d6..533cf15332 100644 --- a/packages/subagent/subagent-control/src/index.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1,10 +1,7 @@ /** - * Continuable-subagent control service (`ctx.subagentControl`): stable child - * ids, descriptor persistence and lookup by known child id, Task-backed - * activation, and steer-or-resume message routing. The low-level - * `ctx.subagents` seam stays collection-, Task-, and persistence-agnostic; - * this service owns the policy that binds one durable child session to a - * series of disposable Task-backed activations. + * 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`. * * Every continuable activation — initial or resumed, parent- or human-started * — has exactly one Task and one result. Task settlement awaits the child @@ -13,26 +10,21 @@ * targets the whole activation: parent and human messages that joined one * turn share its result and its `killed` outcome. * - * @module @deepseek-ai/dsh-subagent-control + * @module @deepseek-ai/dsh-subagent */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' +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 '@deepseek-ai/dsh-subagent' -import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' +import type { SubagentService } from './index.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' -declare module 'cordis' { - interface Context { - subagentControl: SubagentControlService - } -} - /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { readonly kind: 'coordinator' @@ -46,7 +38,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Typed error for control-service routing, authorization, and delivery failures. */ +/** Typed error for continuation routing, authorization, and delivery failures. */ export class SubagentControlError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) @@ -68,7 +60,7 @@ export interface ContinuableStartSpec { readonly request: Omit } -/** Identities returned by {@link SubagentControlService.startContinuable}. */ +/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId @@ -77,7 +69,7 @@ export interface ContinuableStart { } /** - * How {@link SubagentControlService.sendMessage} delivered a message: + * How {@link SubagentContinuationManager.sendMessage} delivered a message: * `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 message. Failure is an exception, never a result — @@ -173,14 +165,14 @@ function finalText(blocks: ContentBlock[]): string { * boundary, while foreground one-shot delegation keeps calling * `ctx.subagents.start()` directly. */ -export class SubagentControlService extends Service { - static inject = ['subagents', 'tasks', 'agents'] - +export class SubagentContinuationManager { /** Child session id → its current activation. Process-local, never durable. */ private activations = new Map() - constructor(ctx: Context) { - super(ctx, 'subagentControl') + constructor( + private readonly ctx: Context, + private readonly subagents: SubagentService, + ) { // 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) => { @@ -189,7 +181,7 @@ export class SubagentControlService extends Service { } }) // TaskService deliberately keeps producer Tasks alive across a - // control-surface or producer reload, so this service's disposal must not + // 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 @@ -198,7 +190,7 @@ export class SubagentControlService extends Service { const active = [...this.activations.values()] this.activations.clear() for (const activation of active) { - activation.controller.abort('subagent control service disposed') + activation.controller.abort('subagent continuation manager disposed') activation.terminal.resolve() } await Promise.allSettled(active.map((activation) => { @@ -207,7 +199,7 @@ export class SubagentControlService extends Service { if (activation.done === undefined) return Promise.resolve() return activation.done })) - }, 'subagentControl.activations()') + }, 'subagents.continuations()') } /** @@ -239,7 +231,7 @@ export class SubagentControlService extends Service { ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.ctx.subagents.start(spec.provider, { + this.subagents.start(spec.provider, { ...request, signal, continuation: { sessionId: childId, descriptor }, @@ -294,7 +286,7 @@ export class SubagentControlService extends Service { const activation = this.activations.get(childId) if (activation === undefined) { throw new SubagentControlError( - `subagent "${childId}" has a live agent outside control-service ownership; the message was not delivered`, + `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) } @@ -399,7 +391,7 @@ export class SubagentControlService extends Service { 'NOT_RESUMABLE', ) } - return this.ctx.subagents.resume(descriptor.provider, { + return this.subagents.resume(descriptor.provider, { sessionId: childId, prompt: message, source, @@ -501,4 +493,4 @@ function resumeLabel(message: ContentBlock[]): string { return text.length > 80 ? `${text.slice(0, 79)}…` : text } -export default SubagentControlService +export default SubagentContinuationManager diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 00942ca448..27b404fed9 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -3,7 +3,7 @@ * `subagent/descriptor` session event that records a child's declared * composition so a known child id can be cold-resumed after its run — and its * process — are gone. Providers append it turn-enclosed in the child's initial - * turn; the control service folds it back on resume. + * turn; the continuation manager folds it back on resume. * * The descriptor deliberately snapshots explicit fields rather than the * merge-extensible `AgentOptions` object: an unrelated extension value cannot diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4f9a013084..f81da156eb 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,13 +13,11 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Scope: the seam stays collection-, Task-, and persistence-agnostic — a run - * is started or resumed and its `result` awaited, whether the consumer blocks - * on it (foreground) or registers it as a `ctx.tasks` background task (the - * generic runtime owns ids/polling/stop; this seam gains nothing task-shaped). - * Durable continuable-child ids, descriptor lookup, and Task association - * belong to `@deepseek-ai/dsh-subagent-control`; this service only validates - * and dispatches `start`/`resume` and observes run lifecycle. + * Raw `start` and `resume` remain collection-agnostic provider dispatch. + * When `ctx.tasks` and `ctx.agents` are available, the same service also binds + * an internal continuation manager for durable child ids, descriptor lookup, + * Task-backed activations, and steer-or-resume delivery. Persistence remains + * optional and is required only when a continuation operation is called. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -35,7 +33,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { @@ -47,6 +45,12 @@ import type { SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' +import SubagentContinuationManager from './continuation.ts' +import type { + ContinuableStart, + ContinuableStartSpec, + SendMessageResult, +} from './continuation.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -67,6 +71,17 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { + runOutcome, + settleRun, + SubagentControlError, +} from './continuation.ts' +export type { + ContinuableStart, + ContinuableStartSpec, + CoordinatorMessageSource, + SendMessageResult, +} from './continuation.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -187,12 +202,49 @@ export class SubagentError extends HarnessError { } } -/** Named provider registry and capability-checked start surface. */ +/** Named provider registry with raw and Task-backed continuation 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, this) + this.continuations = manager + childCtx.effect(() => () => { + /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ + if (this.continuations === manager) this.continuations = undefined + }, 'subagents.continuationBinding()') + }) + } + + /** + * 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. + */ + startContinuable(spec: ContinuableStartSpec): ContinuableStart { + return this.requireContinuations().startContinuable(spec) + } + + /** + * Deliver a message to a continuable child by steering its live activation + * or cold-resuming a fresh Task-backed activation. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param message - user-role content to deliver. + * @param source - durable caller attribution. + * @returns the existing steered Task or newly started Task. + */ + sendMessage( + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + ): Promise { + return this.requireContinuations().sendMessage(parent, childId, message, source) } /** @@ -264,7 +316,7 @@ export class SubagentService extends Service { /** * Resume a persisted continuable child through the named provider's * `resume` capability, with the same run lifecycle observation as - * {@link start}. The caller (the control service) has already loaded the + * {@link start}. The internal continuation manager has already loaded the * child, folded its descriptor, and authorized the parent; this method owns * only capability-checked dispatch. * @param name - the provider recorded in the child's descriptor. @@ -291,6 +343,17 @@ export class SubagentService extends Service { return provider } + /** Resolve the optional Task-backed continuation runtime or fail loud. */ + private requireContinuations(): SubagentContinuationManager { + if (this.continuations === undefined) { + throw new SubagentError( + 'continuable subagents require the tasks and agents services', + 'CONTINUATION_UNAVAILABLE', + ) + } + return this.continuations + } + /** 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()) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index aa215be03c..50922b183e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -94,7 +94,7 @@ export interface SubagentStartRequest { */ readonly persona?: string /** - * Continuable-child intent, resolved by the control service before start. + * Continuable-child intent, resolved by `ctx.subagents` 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 @@ -106,10 +106,10 @@ export interface SubagentStartRequest { /** * The resolved continuable-child identity and durable composition record a - * control-service caller attaches to a start request. + * continuation caller attaches to a start request. */ export interface SubagentContinuation { - /** Control-allocated stable child session id, published verbatim. */ + /** Service-allocated stable child session id, published verbatim. */ readonly sessionId: SessionId /** Snapshotted descriptor persisted in the child log for cold resume. */ readonly descriptor: SubagentDescriptorData @@ -117,7 +117,7 @@ export interface SubagentContinuation { /** * What a caller asks for when resuming a persisted continuable child. The - * control service loads the child log, folds and authorizes its descriptor, + * continuation manager 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 diff --git a/packages/subagent/subagent-control/tests/subagent-control.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts similarity index 90% rename from packages/subagent/subagent-control/tests/subagent-control.spec.ts rename to packages/subagent/subagent/tests/continuation.spec.ts index 9e9e4573e8..5a0d20c481 100644 --- a/packages/subagent/subagent-control/tests/subagent-control.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' 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' @@ -18,7 +17,12 @@ 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 SubagentControlService, { runOutcome, settleRun, SubagentControlError } from '../src/index.ts' +import SubagentService, { + runOutcome, + settleRun, + SubagentControlError, + SUBAGENT_DESCRIPTOR_VERSION, +} from '../src/index.ts' type Script = ConstructorParameters[0] @@ -53,12 +57,12 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -/** Boot the full continuable stack: loop, persistence, providers, tasks, control. */ +/** Boot the full continuable stack: loop, persistence, providers, tasks, and subagents. */ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) if (options.persistence !== false) { - const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-control-')) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) } @@ -68,7 +72,6 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(SubagentFork, { providerName: 'fork' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } @@ -93,12 +96,12 @@ async function waitTerminal(ctx: Context, taskId: TaskId, parent: Agent) { } async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { - const control = ctx.subagentControl as unknown as { - activations: Map + const continuations = ctx.subagents as unknown as { + continuations: { activations: Map } } await new Promise((resolve) => { const timer = setInterval(() => { - if (control.activations.get(childId)?.run !== undefined) { + if (continuations.continuations.activations.get(childId)?.run !== undefined) { clearInterval(timer) resolve() } @@ -121,13 +124,13 @@ function sendMessage( childId: SessionId, content: ReturnType, ) { - return ctx.subagentControl.sendMessage(parent, childId, content, { kind: 'user' }) + return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }) } -describe('SubagentControlService.startContinuable', () => { +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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) expect(started.childId).toMatch(/[0-9a-f-]{36}/) expect(started.taskId).toBe('subagent-1') @@ -138,13 +141,13 @@ describe('SubagentControlService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) - it('publishes the control-allocated child id and appends the turn-enclosed descriptor', async () => { + it('publishes the service-allocated child id and appends the turn-enclosed 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const descriptorIndex = seen.findIndex(event => event.type === 'subagent/descriptor') @@ -162,7 +165,7 @@ describe('SubagentControlService.startContinuable', () => { // Model-hidden: the descriptor never carries surface metadata. expect('surfaceOp' in descriptor).toBe(false) - // The durable log kept the exact control-allocated id. + // 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')) @@ -171,7 +174,7 @@ describe('SubagentControlService.startContinuable', () => { it('rejects synchronously with no Task when persistence is not configured', async () => { const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) - expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + expect(() => ctx.subagents.startContinuable(startSpec(parent))) .toThrow(/require session persistence/) expect(ctx.tasks.list(parent)).toEqual([]) }) @@ -181,19 +184,21 @@ describe('SubagentControlService.startContinuable', () => { const realStart = ctx.tasks.start.bind(ctx.tasks) ctx.tasks.start = () => { throw new Error('task preflight failed') } try { - expect(() => ctx.subagentControl.startContinuable(startSpec(parent))) + expect(() => ctx.subagents.startContinuable(startSpec(parent))) .toThrow('task preflight failed') } finally { ctx.tasks.start = realStart } - const control = ctx.subagentControl as unknown as { activations: Map } - expect(control.activations.size).toBe(0) + 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.subagentControl.startContinuable({ + expect(() => ctx.subagents.startContinuable({ ...spec, // A symbol survives the static ToolRestriction type only through this // cast — exactly the durable-boundary input the snapshot rejects. @@ -214,7 +219,7 @@ describe('SubagentControlService.startContinuable', () => { maxDepth: 0, }, } - const started = ctx.subagentControl.startContinuable(spec) + const started = ctx.subagents.startContinuable(spec) const snapshot = await waitTerminal(ctx, started.taskId, parent) expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') @@ -228,7 +233,7 @@ describe('SubagentControlService.startContinuable', () => { it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { const { ctx, parent } = await setup(['hang']) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + 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() @@ -250,7 +255,7 @@ describe('SubagentControlService.startContinuable', () => { checkpointStarted.resolve(undefined) await releaseCheckpoint.promise }) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await checkpointStarted.promise expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') @@ -262,7 +267,7 @@ describe('SubagentControlService.startContinuable', () => { }) }) -describe('SubagentControlService.sendMessage', () => { +describe('SubagentService.sendMessage', () => { it('omits undeclared model selectors and rejects a provider without live delivery', async () => { const { ctx } = await setup([]) const result = Promise.withResolvers<{ @@ -286,7 +291,7 @@ describe('SubagentControlService.sendMessage', () => { resume: async () => { throw new Error('not used') }, }) const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'no-steer')) + const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer')) await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) @@ -336,7 +341,7 @@ describe('SubagentControlService.sendMessage', () => { }, resume: async () => { throw new Error('not used') }, }) - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'mismatched-local')) + const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) await expect(sendMessage(ctx, parent, started.childId, message('join'))) @@ -357,7 +362,7 @@ describe('SubagentControlService.sendMessage', () => { ]) const { ctx, parent } = await setupWith(adapter) - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + 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(() => { @@ -368,7 +373,7 @@ describe('SubagentControlService.sendMessage', () => { }, 5) }) - const delivery = ctx.subagentControl.sendMessage( + const delivery = ctx.subagents.sendMessage( parent, started.childId, message('also consider Y'), @@ -406,7 +411,7 @@ describe('SubagentControlService.sendMessage', () => { }) const base = startSpec(parent) - const started = ctx.subagentControl.startContinuable({ + const started = ctx.subagents.startContinuable({ ...base, request: { ...base.request, @@ -419,7 +424,7 @@ describe('SubagentControlService.sendMessage', () => { }) await startedTool.promise - const delivery = ctx.subagentControl.sendMessage( + const delivery = ctx.subagents.sendMessage( parent, started.childId, message('follow-up that terminal policy rejects'), @@ -437,11 +442,11 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = await ctx.subagentControl.sendMessage( + const followUp = await ctx.subagents.sendMessage( parent, started.childId, message('and then?'), @@ -476,7 +481,7 @@ describe('SubagentControlService.sendMessage', () => { toolFilter: { deny: [] as string[] }, }, } - const started = ctx.subagentControl.startContinuable(spec) + const started = ctx.subagents.startContinuable(spec) await waitTerminal(ctx, started.taskId, parent) const loaded = await ctx.sessionPersistence.load(started.childId) @@ -503,7 +508,7 @@ describe('SubagentControlService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) await parent.whenIdle() - const started = ctx.subagentControl.startContinuable(startSpec(parent, 'fork')) + 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 @@ -527,7 +532,7 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const followUp = await sendMessage(ctx, parent, started.childId, message('go on')) @@ -546,7 +551,7 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(otherParent)) + const started = ctx.subagents.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) const attempt = await sendMessage(ctx, parent, started.childId, message('mine now')) @@ -590,9 +595,9 @@ describe('SubagentControlService.sendMessage', () => { ]) }) - it('rejects delivery to a live agent outside control-service ownership', async () => { + it('rejects delivery to a live agent outside continuation ownership', async () => { const { ctx, parent } = await setup([textResponse('unused')]) - // A live child created around the control service. + // A live child created outside continuation orchestration. const handle = await ctx.agents.create({ sessionId: SessionId('rogue-child'), meta: { parentSession: parent.id }, @@ -601,7 +606,7 @@ describe('SubagentControlService.sendMessage', () => { await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(SubagentControlError) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(/outside control-service ownership.*not delivered/) + .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() }) @@ -625,7 +630,7 @@ describe('SubagentControlService.sendMessage', () => { } } - const started = ctx.subagentControl.startContinuable(startSpec(parent)) + 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) => { @@ -654,7 +659,7 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const followUp = await sendMessage(ctx, parent, started.childId, message('more')) const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) @@ -663,7 +668,7 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) // Make the persistence load hang until the kill lands. @@ -686,7 +691,7 @@ describe('SubagentControlService.sendMessage', () => { 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.subagentControl.startContinuable(startSpec(parent)) + const started = ctx.subagents.startContinuable(startSpec(parent)) await waitTerminal(ctx, started.taskId, parent) const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) @@ -715,15 +720,15 @@ 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-control-hmr-')) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SubagentService) + 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 control service is disposed. + // is observably mid-start when the subagent service is disposed. let sawAbort = false ctx.subagents.registerProvider({ name: 'pending', @@ -737,19 +742,17 @@ describe('service disposal with live activations', () => { }), resume: () => Promise.reject(new Error('unreachable')), }) - const controlFiber = await ctx.plugin(SubagentControlService) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - const control = ctx.get('subagentControl')! - const started = control.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: 'pending', label: 'will be interrupted', request: { prompt: message('go'), parent }, }) - // LocalTaskService keeps the producer Task; the disposing control service must + // LocalTaskService keeps the producer Task; the disposing subagent service must // cancel its activation and await settlement rather than strand it. - await controlFiber.dispose() + await subagentsFiber.dispose() expect(sawAbort).toBe(true) const snapshot = await waitTerminal(ctx, started.taskId, parent) expect(snapshot.status).toBe('killed') diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 69dacf70f6..90128302c5 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -125,6 +125,17 @@ describe('SubagentService', () => { })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) }) + it('rejects Task-backed continuation 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' })) + }) + it.each([ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 713e214f04..6684758659 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/scope" }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../tasks/tasks" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 6a6026e3fd..c308e11d99 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-tool-subagent-control -The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls. +The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. 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 control 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 renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered. +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 renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 96c91c4ec6..4fdd483ff7 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", - "description": "Globally named send_message tool over the continuable-subagent control service", + "description": "Globally named send_message tool over ctx.subagents continuations", "version": "0.0.1", "private": true, "type": "module", @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent-control": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -44,7 +44,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 959ff8eb49..d95ecb77a9 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,7 +1,7 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its - * own — steer-or-resume orchestration belongs to the control service — and it + * `ctx.subagents.sendMessage()`. 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. * @module @deepseek-ai/dsh-tool-subagent-control @@ -11,14 +11,14 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-subagent-control' +import type {} from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent-control' -export const inject = ['tools', 'subagentControl'] +export const inject = ['tools', 'subagents'] /** * Register the `send_message` tool. - * @param ctx - context carrying the tool registry and the control service. + * @param ctx - context carrying the tool registry and subagent service. */ export function apply(ctx: Context): void { ctx.tools.register(defineTool({ @@ -67,7 +67,7 @@ export function apply(ctx: Context): void { throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagentControl.sendMessage( + const result = ctx.subagents.sendMessage( parent, SessionId(args.subagent_id), message, diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts index 6fb1c19ea6..c993426a26 100644 --- a/packages/subagent/tool-subagent-control/src/invariant.ts +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery - * and activation relations are owned by the control service it calls. + * and activation relations are owned by the subagent service it calls. */ const install: InvariantInstaller = () => {} 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 93c401007e..02c7e6c1a3 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 @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' 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' @@ -34,7 +33,6 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) await ctx.plugin(tool) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) @@ -68,7 +66,7 @@ describe('dsh-tool-subagent-control', () => { it('cold-resumes a settled child and renders the started route with its task id', async () => { const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) - const started = ctx.subagentControl.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: 'spawn', label: 'work', request: { prompt: [{ type: 'text', text: 'child task' }], parent }, @@ -98,10 +96,10 @@ describe('dsh-tool-subagent-control', () => { const { ctx, parent } = await setup([]) let steered: string | undefined let source: unknown - // Reach past the tool into the control service to fake a running route + // 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.subagentControl.sendMessage = async (agent, _childId, message, messageSource) => { + ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => { steered = (message[0] as { text: string }).text source = messageSource return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } @@ -116,7 +114,7 @@ describe('dsh-tool-subagent-control', () => { expect(text(result)).toBe('message delivered to running task subagent-9') }) - it('reports a control-service failure as an errored, not-delivered result', async () => { + it('reports a delivery failure as an errored, not-delivered result', async () => { const { ctx, parent } = await setup([]) const result = await callTool(ctx, 'send_message', { subagent_id: 'no-such-child', @@ -148,7 +146,6 @@ describe('dsh-tool-subagent-control', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) - await ctx.plugin(SubagentControlService) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) await fiber.dispose() @@ -158,7 +155,7 @@ describe('dsh-tool-subagent-control', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-control') - expect(tool.inject).toEqual(['tools', 'subagentControl']) + expect(tool.inject).toEqual(['tools', 'subagents']) expect(typeof tool.apply).toBe('function') }) }) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json index 4b2ec045e6..3a57a0437e 100644 --- a/packages/subagent/tool-subagent-control/tsconfig.json +++ b/packages/subagent/tool-subagent-control/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../core/tools" }, { - "path": "../subagent-control" + "path": "../subagent" }, { "path": "../../support/invariants" diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index c4660b5517..46daae94ea 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: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1 -README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a +README.md: 9d60363602a9825730984700a7fe987d911e1cac +README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 7d32da3c97..9d60363602 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`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent as task `, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task `. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; 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) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.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 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). `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,6 +21,7 @@ With `run_in_background: true`, the route follows the provider's continuation ca | `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. | | `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. | @@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent as task ` on a resumable provider, or `started background subagent task ` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child. +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. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index eadc168fd0..5964c38bd8 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` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent as task `,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task `。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent 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)。 +设置 `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)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,6 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -64,7 +65,7 @@ #### 模型看到的内容 -对于可恢复提供方,启动时精确返回 `started subagent as task `;对于一次性提供方,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。 +在已配置的 continuable 模式下,启动时精确返回 `started subagent as task `;在已配置的 one-shot 模式下,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index d789c9b4f9..353f67ff24 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-control": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -48,7 +47,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 560e0cb20b..6033bea875 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,11 +1,10 @@ /** * Model-facing delegation through one configured `ctx.subagents` provider. * Provider lifecycle controls tool registration and context-sensitive schema - * wording. Foreground calls always dispose the run after collection. A - * background call's route follows the provider's continuation capability: - * a provider with `resume` delegates to `ctx.subagentControl`, which owns the - * durable child id, its descriptor, and the Task-backed activation lifecycle; - * a provider without it (ACP) keeps the one-shot background task. + * wording. Foreground calls always dispose the run after collection. + * Background policy is selected by this plugin's configuration: one-shot + * calls own a plain Task, while continuable calls use + * `ctx.subagents.startContinuable()`. * @module @deepseek-ai/dsh-tool-subagent */ @@ -15,9 +14,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' +import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' -import { settleRun } from '@deepseek-ai/dsh-subagent-control' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' export const name = 'tool-subagent' @@ -37,6 +35,12 @@ export interface Config { * parameter and reject forced background calls. */ 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. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -73,6 +77,7 @@ export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), enableRunInBackground: z.boolean().default(true), + backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), @@ -191,17 +196,19 @@ export function apply(ctx: Context, config: Config): void { } const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false - // The provider's continuation capability decides the background route: a - // resumable provider starts durable, follow-up-able children through the - // control service, while a one-shot provider (ACP) keeps the plain task. - const continuable = provider.resume !== undefined + const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' + if (continuable && provider.resume === undefined) { + throw new Error( + `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, + ) + } disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled ? continuable ? ' Set `run_in_background: true` to start a continuable background subagent: you receive its' - + ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,' - + ' and send follow-up messages with `send_message`.' + + ' 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 return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { @@ -220,7 +227,7 @@ export function apply(ctx: Context, config: Config): void { type: 'boolean' as const, description: continuable ? 'Run as a continuable background subagent and return its subagent and task ids; ' - + 'collect with task_output, stop with task_kill, follow up with send_message.' + + 'collect with task_output or stop with task_kill.' : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, @@ -281,23 +288,7 @@ 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 control = ctx.get('subagentControl') - if (control === undefined) { - throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks') - } - // The schema above tells the model to follow up with - // `send_message`; starting a durable child the model cannot - // continue would make that advertisement false. Sibling load order - // is undetermined at mount, so the check lives at the operation, - // and it resolves in the CALLER's scope so a restriction that - // removes send_message from this agent also blocks the start. - if (ctx.tools.get('send_message', parent) === undefined) { - throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)') - } - // The control service owns the durable child id, descriptor - // snapshot, Task registration, and settle-then-dispose ordering; a - // synchronous validation failure rejects the call with no Task. - const started = control.startContinuable({ + const started = ctx.subagents.startContinuable({ provider: config.provider, label: args.description, request, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index f631133970..1b45640a94 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -15,9 +15,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' @@ -70,6 +68,21 @@ 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 () => { + let failure: unknown + try { + await setup({ + provider: 'mock', + backgroundMode: 'continuable', + }) + } catch (error: unknown) { + failure = error + } + expect(String(failure)).toContain( + 'provider "mock" does not support `backgroundMode: continuable`', + ) + }) + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) @@ -655,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => { return ctx } + it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }) + const parent = ownerAgent(ctx, 'sess-parent') + let resumeCalls = 0 + ctx.subagents.registerProvider({ + name: 'resumable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async request => ({ + id: SessionId('one-shot-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'one-shot answer' }], + stopReason: request.signal.aborted ? 'aborted' : 'completed', + }), + dispose: () => Promise.resolve(), + }), + resume: async () => { + resumeCalls += 1 + throw new Error('one-shot policy must not resume') + }, + }) + tool.apply(ctx, { + provider: 'resumable', + toolName: 'subagent_resumable', + backgroundMode: 'one-shot', + maxDepth: 'provider-managed', + }) + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('resumable-one-shot'), + name: 'subagent_resumable', + arguments: { description: 'work', prompt: 'go', run_in_background: true }, + agent: parent, + }) + + expect(text(started)).toBe('started background subagent task subagent-1') + expect(resumeCalls).toBe(0) + }) + it('returns a task id immediately and the answer is collected through task_output', async () => { const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' }) const parent = ownerAgent(ctx, 'sess-parent') @@ -825,8 +879,8 @@ describe('dsh-tool-subagent continuable background mode', () => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) - /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ - async function continuableSetup(options: { controlTool?: boolean } = {}) { + /** Boot the real continuable stack without any model-facing follow-up adapter. */ + async function continuableSetup() { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) @@ -837,9 +891,7 @@ describe('dsh-tool-subagent continuable background mode', () => { await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) - await ctx.plugin(SubagentControlService) - if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl) - await ctx.plugin(tool, { provider: 'spawn' }) + await ctx.plugin(tool, { provider: 'spawn', backgroundMode: 'continuable' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), ])) @@ -847,10 +899,10 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } - it('a resumable provider advertises send_message and returns both ids', async () => { + it('starts a continuable child and returns both ids without send_message', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! - expect(schema.description).toContain('send_message') + expect(schema.description).not.toContain('send_message') const started = await callSubagent( ctx, @@ -869,56 +921,6 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) }) - it('fails loud when the provider is resumable but the control service is not loaded', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(SubagentService) - // A resumable provider without ctx.subagentControl. - ctx.subagents.registerProvider({ - name: 'resumable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: () => { throw new Error('unreachable') }, - resume: () => { throw new Error('unreachable') }, - }) - await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' }) - - const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control') - }) - - it('fails loud when the advertised send_message tool is not registered', async () => { - // The schema tells the model to follow up with send_message; starting a - // durable child the model cannot continue would make that false. - const { ctx, parent } = await continuableSetup({ controlTool: false }) - const result = await callSubagent( - ctx, - { description: 'd', prompt: 'p', run_in_background: true }, - { agent: parent }, - ) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') - // Nothing was started: no Task exists for the parent. - expect(ctx.tasks.list(parent)).toEqual([]) - }) - - it('resolves send_message availability in the CALLER scope, not the global registry', async () => { - // A scoped restriction that keeps this delegation tool but removes - // send_message means this agent cannot execute the promised follow-up; - // the availability check must see the caller's surface. - const { ctx, parent } = await continuableSetup() - parent.ctx.tools.restrict({ deny: ['send_message'] }) - const result = await callSubagent( - ctx, - { description: 'd', prompt: 'p', run_in_background: true }, - { agent: parent }, - ) - expect(result.isError).toBe(true) - expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control') - expect(ctx.tasks.list(parent)).toEqual([]) - }) }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index a542b520b1..25780c367f 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../subagent" }, - { - "path": "../subagent-control" - }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80d30aeae9..45790d4591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,9 +384,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork @@ -737,9 +734,6 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:* - version: link:../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -4885,6 +4879,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4932,51 +4932,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/subagent/subagent-control: - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../subagent-spawn - '@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 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/subagent/subagent-dsh-sdk: dependencies: schemastery: @@ -5184,9 +5139,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../subagent-control '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -5238,9 +5190,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../subagent-control '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn @@ -6510,9 +6459,6 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:^ version: link:../../packages/subagent/subagent-acp - '@deepseek-ai/dsh-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-control '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../packages/subagent/subagent-fork diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index df5d302915..a4d555055d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -66,7 +66,6 @@ "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", - "@deepseek-ai/dsh-subagent-control": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 02215862db..ed46fd687d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -424,19 +424,11 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'subagents', pkg: 'subagent', - title: 'Subagent provider registry', + title: 'Subagent provider and continuation service', mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], - consumers: ['tool-subagent', 'tool-ralph'], - note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', - }, - { - key: 'subagentControl', - pkg: 'subagent', - title: 'Continuable-subagent control service', - mode: 'core', - consumers: ['tool-subagent', 'tool-subagent-control'], - note: 'Binds one durable child session to Task-backed activations over ctx.subagents; tool-subagent starts continuable background children and tool-subagent-control delivers follow-up messages.', + 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.', }, { key: 'tasks', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 9d3907821a..96d6a97457 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -28,7 +28,6 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' -import SubagentControlService from '@deepseek-ai/dsh-subagent-control' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' @@ -108,8 +107,6 @@ 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')), - // Presence marks the continuation capability, so tool-subagent harvests - // its shipped continuable background wording (spawn/fork are resumable). resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), } ctx.subagents.registerProvider(provider) @@ -388,13 +385,12 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-subagent-control', dir: 'tool-subagent-control', source: 'packages/subagent/tool-subagent-control/src/index.ts', - requires: ['ctx.tools', 'ctx.subagentControl'], - writes: ['tool/call', 'tool/result', 'child session events through the control service'], + requires: ['ctx.tools', 'ctx.subagents'], + writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'], async mount(ctx) { await ctx.plugin(SubagentService) await ctx.plugin(LocalTaskService) await ctx.plugin(AgentRegistry) - await ctx.plugin(SubagentControlService) await ctx.plugin(ToolSubagentControl) }, note: diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9e8f68177d..10c652f704 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1102,7 +1102,7 @@ { "doc": "docs/core-data-structures/subagent.md", "symbol": "CoordinatorMessageSource", - "source": "packages/subagent/subagent-control/src/index.ts" + "source": "packages/subagent/subagent/src/continuation.ts" }, { "doc": "docs/core-data-structures/subagent.md", diff --git a/tsconfig.host.json b/tsconfig.host.json index 3f46bf5ee6..2e5c6ea08c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -178,7 +178,6 @@ { "path": "./packages/support/loader-smoke" }, { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, - { "path": "./packages/subagent/subagent-control" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent-control" }, { "path": "./packages/subagent/subagent-inprocess" }, From efc47b6a760539d9ededccc51b00ff4cffe20a14 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 11:50:21 +0800 Subject: [PATCH 15/90] fix(subagent): require durability participant --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- docs/cordis-catalog/events.md | 9 ++--- docs/cordis-catalog/services.md | 14 +++++++- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +++- packages/core/session/README.i18n.yaml | 4 +-- packages/core/session/README.md | 1 + packages/core/session/README.zh.md | 1 + packages/core/session/src/index.ts | 27 ++++++++++++-- packages/core/session/tests/scoped.spec.ts | 27 ++++++++++++++ .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 36 ++++++++++++++++++- .../subagent/tests/continuation.spec.ts | 27 ++++++++++++-- 18 files changed, 150 insertions(+), 26 deletions(-) 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 d9be549a27..178ed15509 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: 42e9f6dd653dee8df4b8c068e3a33b7c69f8dc4b -2026-07-21-continuable-background-subagents.zh.md: e643e81f40074c3cc0cd6398b3f1d24e0d9bc988 +2026-07-21-continuable-background-subagents.md: fc1cd97dae2583f6d78ee1413561002b6391a0c9 +2026-07-21-continuable-background-subagents.zh.md: 31775d9c2b8bd6b2dc84b5e86d4dbc9d1bd8a1f5 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 42e9f6dd65..fc1cd97dae 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 @@ -31,7 +31,7 @@ The low-level `ctx.subagents` seam stays collection-, Task-, and persistence-agn ### 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. +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 required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint 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. @@ -109,7 +109,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) 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 service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) 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 service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. 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 e643e81f40..31775d9c2b 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 @@ -31,7 +31,7 @@ durable child Session ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点失败时,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将后端失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 +初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 @@ -109,7 +109,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因,循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index db6af68970..4624b54dec 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -685,13 +685,14 @@ Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/sr ### `session/flush` — parallel -Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. An empty listener snapshot is accepted by SessionStore.flush and rejected by SessionStore.flushRequired. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. An empty listener + * snapshot is accepted by {@link SessionStore.flush} and rejected by + * {@link SessionStore.flushRequired}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -702,7 +703,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b0d07711f2..3c454115af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1640,6 +1640,18 @@ announce(session: Session): void */ async flush(session: Session): Promise +/** + * Dispatch the same awaited checkpoint as {@link flush}, but reject when its + * scoped listener snapshot is empty. Callers use this operation when success + * requires an installed durability participant rather than optional + * best-effort persistence. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when at least one listener participated and every + * listener settled successfully. + * @throws when no listener is registered or any registered listener fails. + */ +async flushRequired(session: Session): Promise + /** * Look up a live session. * @param id - the session id to look up. @@ -1672,7 +1684,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:765`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:766`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 204bae369d..31f5204a52 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `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) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 19f7707262..80fb096b4f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -746,6 +746,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async flush(session: Session): Promise', jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', }, + { + signature: 'async flushRequired(session: Session): Promise', + jsDoc: '/**\n * Dispatch the same awaited checkpoint as {@link flush}, but reject when its\n * scoped listener snapshot is empty. Callers use this operation when success\n * requires an installed durability participant rather than optional\n * best-effort persistence.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when at least one listener participated and every\n * listener settled successfully.\n * @throws when no listener is registered or any registered listener fails.\n */', + }, { signature: 'get(id: SessionId): Session | undefined', jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */', @@ -1410,7 +1414,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. An empty listener\n * snapshot is accepted by {@link SessionStore.flush} and rejected by\n * {@link SessionStore.flushRequired}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 3dc9877266..88ac2bd405 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: 9c7d41901e6fb0133fff0e210260e5310a025f75 -README.zh.md: ca1292289901a09b83f9b0a794fa4edc9754b1da +README.md: 59e8694a957e9742a22662766d671dc2145c44e3 +README.zh.md: 7618bc8f3a9146a4fc5afbfb19317deef7f13068 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 9c7d41901e..59e8694a95 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -14,6 +14,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. +- `ctx.sessions.flushRequired(session)` uses the same dispatch but also rejects an empty scoped listener snapshot. Callers use it when success requires an installed durability participant rather than optional best-effort persistence. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ca12922899..7618bc8f3a 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -14,6 +14,7 @@ - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 +- `ctx.sessions.flushRequired(session)` 沿用相同的分发逻辑,但也会拒绝空的作用域监听器快照。若成功要求已安装的持久性参与方介入,而不是采用可选的尽力持久化,调用方应使用此方法。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 8bbf783bc2..55c3a11e83 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -93,8 +93,9 @@ declare module 'cordis' { 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. An empty listener + * snapshot is accepted by {@link SessionStore.flush} and rejected by + * {@link SessionStore.flushRequired}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -973,9 +974,31 @@ export class SessionStore extends Service { * rejects with the first registered listener failure if any listener failed. */ async flush(session: Session): Promise { + await this.dispatchFlush(session, false) + } + + /** + * Dispatch the same awaited checkpoint as {@link flush}, but reject when its + * scoped listener snapshot is empty. Callers use this operation when success + * requires an installed durability participant rather than optional + * best-effort persistence. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when at least one listener participated and every + * listener settled successfully. + * @throws when no listener is registered or any registered listener fails. + */ + async flushRequired(session: Session): Promise { + await this.dispatchFlush(session, true) + } + + /** Dispatch one optional or required flush listener snapshot. */ + private async dispatchFlush(session: Session, requireListener: boolean): Promise { const { carrier } = this.liveEntryFor(session) const callbackArgs: unknown[] = [session] const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]) + if (requireListener && callbacks.length === 0) { + throw new Error(`session "${session.id}" required durability checkpoint has no registered listener`) + } const results = await Promise.allSettled(callbacks.map((callback) => { try { return callback(...callbackArgs) diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 7a5e617254..a524d9cb86 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -80,6 +80,33 @@ describe('session dispatch carriers', () => { }) describe('sessions.flush()', () => { + it('allows an ordinary flush with no listeners', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + + await expect(ctx.sessions.flush(session)).resolves.toBeUndefined() + }) + + it('rejects a required flush with no listeners', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + + await expect(ctx.sessions.flushRequired(session)).rejects.toThrow( + `session "${session.id}" required durability checkpoint has no registered listener`, + ) + }) + + it('completes a required flush when a listener succeeds', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + const flushed: Session[] = [] + ctx.on('session/flush', current => void flushed.push(current)) + + await ctx.sessions.flushRequired(session) + + expect(flushed).toEqual([session]) + }) + it('dispatches session/flush with the owning carrier and awaits all listeners', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 3cf7ffb480..10e2e40108 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: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab -README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591 +README.md: afc92cf4f38830c22a2de401620e0223e7bf62d1 +README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1bbbfd282f..afc92cf4f3 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 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 one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. 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. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend 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. +5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and 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. 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index d6dc91415b..dcf7d34390 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。 +5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 20c258e195..a17cd15be1 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -270,7 +270,7 @@ function driveTurn( await child.whenIdle() if (durability === 'required') { try { - await child.ctx.sessions.flush(child.session) + await child.ctx.sessions.flushRequired(child.session) } catch (error: unknown) { if (!signal.aborted) { throw new SubagentError( diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 50f8b03370..d052a3a1e1 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -73,6 +73,40 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + 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') @@ -312,7 +346,7 @@ describe('startInProcessRun', () => { acceptsNextStep: false, ctx: { sessions: { - flush: () => { + flushRequired: () => { flushes++ return Promise.resolve() }, diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 5a0d20c481..ede4726c5e 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -61,10 +61,12 @@ afterEach(() => { async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + let disposePersistence: (() => Promise) | undefined if (options.persistence !== false) { const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) - await ctx.plugin(JsonlSessionPersistence, { root }) + const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) + disposePersistence = () => persistenceFiber.dispose() } await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) @@ -74,7 +76,7 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } await ctx.plugin(ToolTasks, {}) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + return { ctx, parent, disposePersistence } } async function setup(script: Script, options: { persistence?: boolean } = {}) { @@ -141,6 +143,25 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + 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('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => { const { ctx, parent } = await setup([textResponse('answer')]) const seen: SessionEvent[] = [] From ddab8b84c00af41c80524731ad8f351d914e309c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 12:43:23 +0800 Subject: [PATCH 16/90] test(goal-session): cover unsettled attempt yield --- .../goal-session/tests/goal-session.spec.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 5a3f0bdca2..073cb5002f 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { @@ -787,6 +787,35 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) + it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { + const test = await harness([textResponse('round ran')]) + // A persistent pre-commit turn/end rejection reaches idle with the + // attempt's turn open and no terminal reason. The driver must yield + // instead of clearing the reservation or scheduling another round. + let roundTurn: number | undefined + test.ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/start' && event.data.trigger.kind === 'message' + && event.data.trigger.source.kind === 'goal') { + roundTurn = event.data.turn + } + if (event.type === 'turn/end' && event.data.turn === roundTurn) { + throw new Error('turn close permanently rejected') + } + }) + test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' }) + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + await new Promise((resolve) => { setImmediate(resolve) }) + + expect(test.adapter.requests).toHaveLength(1) + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'armed', + }) + }) + it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined From 0821ceb03337f2d44a7023b1c0809c47e15afd41 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 13:20:28 +0800 Subject: [PATCH 17/90] fix(sdk): mount local task registry for subagents --- packages/sdk/helper/src/features/builtin/index.ts | 2 +- packages/sdk/helper/tests/project.spec.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 139eae3bde..2aa6c40e71 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -212,7 +212,7 @@ config: // In-process options select continuable background delegation; the // follow-up adapter remains an independently loadable global tool. baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' }, + { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' }, { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d006465e77..763558afab 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -206,12 +206,13 @@ describe('SdkProject and ProjectEditSession', () => { it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { const project = await createCommitted([selection('subagent', [option])]) - expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks') + expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local') expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') expect(project.packageManifest().dependencies).toMatchObject({ - '@deepseek-ai/dsh-tasks': '^0.0.1', + '@deepseek-ai/dsh-tasks-local': '^0.0.1', '@deepseek-ai/dsh-tool-tasks': '^0.0.1', }) + expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) it('round-trips embed app projects without a front-door Cordis config entry', async () => { From 112e25bb054f49eabc97d6234a70599a4e94e4d7 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 14:08:15 +0800 Subject: [PATCH 18/90] refactor(subagent): unify service errors --- ...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 ++ docs/cordis-catalog/services.md | 2 +- .../subagent-continuable/session.jsonl | 4 +-- .../subagent/subagent/src/continuation.ts | 33 ++++++++----------- packages/subagent/subagent/src/error.ts | 15 +++++++++ packages/subagent/subagent/src/index.ts | 12 ++----- .../subagent/tests/continuation.spec.ts | 4 +-- 9 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 packages/subagent/subagent/src/error.ts 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 1068f5f578..fe5d14796a 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: a5ce673997502ef6fbd7c66ff4e94e301d4192ba -2026-07-26-merge-subagent-control-service.zh.md: cf867ab444438b7ee62cde68a3d53e83c3d049d1 +2026-07-26-merge-subagent-control-service.md: eb8a76dd4dfc5f06deb67608a67c12e061819286 +2026-07-26-merge-subagent-control-service.zh.md: 6599606634a1933790949e8a66df906a0bb9def0 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 a5ce673997..eb8a76dd4d 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 @@ -12,6 +12,8 @@ Continuable-child orchestration originally lived in a separate `ctx.subagentCont `SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. +The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class. + The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations. `startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry. 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 cf867ab444..6599606634 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 @@ -12,6 +12,8 @@ Status: implemented `SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 +合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。 + 继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。 `startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3c454115af..9cb67d757d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2030,7 +2030,7 @@ async resume(name: string, request: SubagentResumeRequest): 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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:206`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:198`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 1b6e576b54..e34205b0f2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -35,7 +35,7 @@ {"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, SubagentControlError: 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":"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}"}}} @@ -44,7 +44,7 @@ {"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, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"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"}}} diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 533cf15332..15bb14bbe4 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -24,6 +24,7 @@ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' import type { SubagentService } from './index.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' +import { SubagentError } from './error.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -38,14 +39,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** Typed error for continuation routing, authorization, and delivery failures. */ -export class SubagentControlError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'SubagentControlError' - } -} - /** What a caller asks for when starting a continuable background child. */ export interface ContinuableStartSpec { /** The `ctx.subagents` provider to establish the child on. */ @@ -285,13 +278,13 @@ export class SubagentContinuationManager { if (live === undefined) return const activation = this.activations.get(childId) if (activation === undefined) { - throw new SubagentControlError( + 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 SubagentControlError( + throw new SubagentError( `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, 'OWNERSHIP_CONFLICT', ) @@ -309,12 +302,12 @@ export class SubagentContinuationManager { 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 SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + 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 SubagentControlError( + 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', @@ -322,10 +315,10 @@ export class SubagentContinuationManager { } const run = activation.run if (run === undefined) { - throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') } if (run.steer === undefined) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, 'NOT_DELIVERED', ) @@ -336,7 +329,7 @@ export class SubagentContinuationManager { // 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 SubagentControlError( + throw new SubagentError( `subagent "${childId}" stopped before delivery; the message was not delivered`, 'NOT_DELIVERED', { cause: error }, @@ -364,18 +357,18 @@ export class SubagentContinuationManager { try { loaded = await persistence.load(childId) } catch (error: unknown) { - throw new SubagentControlError( + 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 SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED') + 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 SubagentControlError( + throw new SubagentError( `subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED', ) @@ -385,7 +378,7 @@ export class SubagentContinuationManager { // itself a continuable child. const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) if (descriptor === undefined) { - throw new SubagentControlError( + throw new SubagentError( `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + 'do not retry send_message with this id', 'NOT_RESUMABLE', @@ -477,7 +470,7 @@ export class SubagentContinuationManager { private requirePersistence(): SessionPersistence { const persistence = this.ctx.get('sessionPersistence') if (persistence === undefined) { - throw new SubagentControlError( + throw new SubagentError( 'continuable subagents require session persistence (load a dsh-session-persistence backend)', 'PERSISTENCE_UNAVAILABLE', ) diff --git a/packages/subagent/subagent/src/error.ts b/packages/subagent/subagent/src/error.ts new file mode 100644 index 0000000000..96409074f5 --- /dev/null +++ b/packages/subagent/subagent/src/error.ts @@ -0,0 +1,15 @@ +/** + * Typed failures shared by subagent service and provider operations. + * + * @module @deepseek-ai/dsh-subagent + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Typed failure for the subagent seam. */ +export class SubagentError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentError' + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index f81da156eb..8809e6d4ea 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -32,7 +32,6 @@ 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 { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -45,6 +44,7 @@ import type { SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' +import { SubagentError } from './error.ts' import SubagentContinuationManager from './continuation.ts' import type { ContinuableStart, @@ -71,10 +71,10 @@ export { SUBAGENT_DESCRIPTOR_VERSION, } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { SubagentError } from './error.ts' export { runOutcome, settleRun, - SubagentControlError, } from './continuation.ts' export type { ContinuableStart, @@ -194,14 +194,6 @@ export interface SubagentRunEndInfo { readonly lastAssistantMessage?: ContentBlock[] } -/** Typed error for provider lookup, registration, and capability failures. */ -export class SubagentError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'SubagentError' - } -} - /** Named provider registry with raw and Task-backed continuation operations. */ export class SubagentService extends Service { private providers = new Map() diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index ede4726c5e..fec3532e2c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -20,7 +20,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import SubagentService, { runOutcome, settleRun, - SubagentControlError, + SubagentError, SUBAGENT_DESCRIPTOR_VERSION, } from '../src/index.ts' @@ -625,7 +625,7 @@ describe('SubagentService.sendMessage', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) - .rejects.toThrow(SubagentControlError) + .rejects.toThrow(SubagentError) await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() From 52002791a47024589efd15745d8b7c886110dd9b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 15:36:17 +0800 Subject: [PATCH 19/90] test(acp): stabilize continuable subagent snapshot --- .../fixtures/subagent-durability-failure.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index 5d0137911d..47f96c0b80 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,9 +1,47 @@ import type { Context } from 'cordis' export const name = 'subagent-durability-failure' +export const inject = ['sessionPersistence', 'tasks'] -/** Fail a continuable child's provider-owned final durability confirmation. */ +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 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. + persistence.load = async (id) => { + if (id === UNKNOWN_CHILD_ID) await thirdStepEnded.promise + return load.call(persistence, id) + } + ctx.effect(() => () => { + persistence.load = load + thirdStepEnded.resolve(undefined) + followUpSettled.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 + } + }) + const flushedTurnEnds = new WeakSet() ctx.on('session/flush', (session) => { if (session.header.parentSession === undefined) return From fea31a012ddc171498ff775d4a867c71e2b21b58 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 17:54:36 +0800 Subject: [PATCH 20/90] fix(subagent): persist descriptor before admission --- ...continuable-background-subagents.i18n.yaml | 4 +-- ...-07-21-continuable-background-subagents.md | 4 +-- ...-21-continuable-background-subagents.zh.md | 4 +-- docs/event-producer-consumer.md | 4 +-- .../subagent-continuable/session.1.jsonl | 8 +++--- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 17 ++++++------ .../subagent/tests/continuation.spec.ts | 27 +++++++++++++++++-- 10 files changed, 49 insertions(+), 27 deletions(-) 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 178ed15509..20ec58ca97 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: fc1cd97dae2583f6d78ee1413561002b6391a0c9 -2026-07-21-continuable-background-subagents.zh.md: 31775d9c2b8bd6b2dc84b5e86d4dbc9d1bd8a1f5 +2026-07-21-continuable-background-subagents.md: 5bbc5fb0b605771b0e7c292412d13ec6e56571ba +2026-07-21-continuable-background-subagents.zh.md: 1864f6d47fe95d9771bb73da72caee6bb415ce0f 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 fc1cd97dae..5bbc5fb0b6 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 @@ -71,7 +71,7 @@ Human input uses the same control operation. The UI may display the child transc ### Durable child handle and cold resume -The control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`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 — 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 control service snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`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 — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives 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](../../../../packages/subagent/subagent/src/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()`](../../../../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. @@ -109,7 +109,7 @@ Task records and active-run associations are process-local. Persistence makes th ## Testing -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) 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 service-allocated child id; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is pre-turn, model-hidden, versioned, durable under the service-allocated child id, and survives blocked or throwing initial prompt admission; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. - `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. 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 31775d9c2b..1864f6d47f 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 @@ -71,7 +71,7 @@ durable child Session ### 持久化 child handle 与从持久化存储恢复 -控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动安装的一次性 `agent/pre-step` 监听器——会在 child 初始 `turn/start` 之后、首次请求之前追加一个对模型隐藏的 `subagent/descriptor` 事件。该事件不携带 `surfaceOp`,不进入模型历史,并随该轮次的 flush 一并进入持久化存储。当压缩替换 surface 历史时,仅追加日志仍保留这个不属于 surface 的事件。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 +控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 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 无法重新获得顶层委派预算。 @@ -109,7 +109,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 测试 -- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次内、对模型隐藏、带版本,并在服务分配的 child id 下持久化;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 - `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 - `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 - `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 31f5204a52..fef8b22088 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,13 +16,13 @@ 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), [`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), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`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) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | 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 c349369a85..15e024fa07 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,8 +1,8 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} -{"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":"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":2,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"subagent/descriptor","seq":3,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"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"}} diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 10e2e40108..89d9134fcb 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: afc92cf4f38830c22a2de401620e0223e7bf62d1 -README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e +README.md: 525760ccc413bb46ca5ea3a37e610a3ff58b8068 +README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index afc92cf4f3..525760ccc4 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -12,7 +12,7 @@ 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 one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush. +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.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index dcf7d34390..4d02e2bb89 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -12,7 +12,7 @@ 1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。 -3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。 +3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index a17cd15be1..5e6abdda06 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -85,17 +85,16 @@ function prePublicationAbort(): Error { /** * Register the one-shot child-scoped contribution that appends the durable - * `subagent/descriptor` event. `agent/step` is the first serial seam - * inside the child's initial turn, so the append lands after `turn/start` and - * before the first request, and reaches persistence with that turn's flush. + * `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 { - let appended = false - childCtx.on('agent/step', (agent) => { - if (appended) return - appended = true + childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => { agent.session.append('subagent/descriptor', descriptor) - }) + return next() + }, { prepend: true }) } /** @@ -103,7 +102,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto * 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 inside the child's initial turn. + * 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. diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index fec3532e2c..1ef761881d 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -162,7 +162,7 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) - it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => { + it('publishes the service-allocated 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) => { @@ -174,7 +174,7 @@ describe('SubagentService.startContinuable', () => { 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') - expect(descriptorIndex).toBeGreaterThan(turnStartIndex) + expect(descriptorIndex).toBeLessThan(turnStartIndex) expect(descriptorIndex).toBeLessThan(firstAssistant) const descriptor = seen[descriptorIndex] as SessionEvent<'subagent/descriptor'> expect(descriptor.data).toEqual({ @@ -193,6 +193,29 @@ describe('SubagentService.startContinuable', () => { 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))) From 644bf00b86b054d9eb1689d2db86a6d42a6369ad Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 19:49:22 +0800 Subject: [PATCH 21/90] fix(subagent): cancel pending live delivery --- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 4 +- ...-21-continuable-background-subagents.zh.md | 4 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/subagent.i18n.yaml | 6 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 122 +++++++++++++++--- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 52 ++++---- .../subagent/subagent/src/continuation.ts | 40 +++++- packages/subagent/subagent/src/index.ts | 5 +- .../subagent/tests/continuation.spec.ts | 28 +++- .../tool-subagent-control/README.i18n.yaml | 6 + .../subagent/tool-subagent-control/README.md | 4 +- .../tool-subagent-control/README.zh.md | 42 ++++++ .../tool-subagent-control/src/index.ts | 1 + .../tests/tool-subagent-control.spec.ts | 51 +++++++- 19 files changed, 320 insertions(+), 65 deletions(-) create mode 100644 packages/subagent/tool-subagent-control/README.i18n.yaml create mode 100644 packages/subagent/tool-subagent-control/README.zh.md 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 20ec58ca97..20623c2641 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: 5bbc5fb0b605771b0e7c292412d13ec6e56571ba -2026-07-21-continuable-background-subagents.zh.md: 1864f6d47fe95d9771bb73da72caee6bb415ce0f +2026-07-21-continuable-background-subagents.md: 4c2cc4ce2538a3d1cf6756168fe4dc1a6448d22a +2026-07-21-continuable-background-subagents.zh.md: 019b623d447781bf254cb241c1b8f1c64fd49c4a 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 5bbc5fb0b6..4c2cc4ce25 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 @@ -39,7 +39,7 @@ Opening a child session in a human-facing adapter reads its persisted transcript `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. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. 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. +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. `sendMessage()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. 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. @@ -57,7 +57,7 @@ The control service does not serialize two callers that race a stopped child thr ### 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. +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 }` and forwards its execution signal; the control service requires both the caller-supplied `MessageSource` and cancellation signal. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. 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' }` and its interaction signal. 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_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. 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 1864f6d47f..019b623d44 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 @@ -39,7 +39,7 @@ durable child Session 如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 -取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`sendMessage()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 @@ -57,7 +57,7 @@ durable child Session ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;控制服务要求调用方提供 `MessageSource`,并在在线 steering 与 cold resume 两条路径中传递该来源。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }`。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发其执行信号;控制服务要求调用方同时提供 `MessageSource` 和取消信号。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9cb67d757d..89a4dd5dfa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1978,9 +1978,11 @@ startContinuable(spec: ContinuableStartSpec): ContinuableStart * @param childId - durable child session id. * @param message - user-role content to deliver. * @param source - durable caller attribution. + * @param signal - caller cancellation; while live delivery awaits admission, + * abort cancels the shared activation so the wait reaches quiescence. * @returns the existing steered Task or newly started Task. */ -sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise +sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index af4b1decda..aaa02e1587 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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 -subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff -subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2 +# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md +subagent.md: 1321429ac9e6280878016601646dd08981ab2b40 +subagent.zh.md: 072b2d2c1635d7c2c59b5a24d2bafc6ee32f8422 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 9864df25bd..1321429ac9 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -105,7 +105,7 @@ interface SubagentStartRequest { ## Continuable children: `SubagentContinuation` and `SubagentResumeRequest` -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 resolved start request; the provider publishes exactly that id and appends the descriptor inside the child's first turn. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. 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 `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource`; the optional model-facing tool uses `CoordinatorMessageSource`, while a human adapter uses `{ kind: 'user' }`. +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 resolved start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. 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 `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal; 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. ```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 2d96e9bc63..072b2d2c16 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,13 +4,13 @@ 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)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.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` 服务通过由 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)。 -源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +源码:[`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')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、恢复)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 +提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。 ```ts type-equiv /** @@ -18,9 +18,10 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba * {@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 — 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. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -88,11 +89,82 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string + /** + * Continuable-child intent, resolved by `ctx.subagents` 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` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 +## 可继续子 agent:`SubagentContinuation` 与 `SubagentResumeRequest` + +**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过已解析的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.sendMessage()` 会先加载并授权已停止的子 agent,再通过底层 `resume()` 操作分发完全解析的恢复请求,或引导其实时激活。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`sendMessage()` 则报告消息是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都会提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record a + * continuation caller attaches to a start request. + */ +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 +} +``` + +```ts type-equiv +/** + * What a caller asks for when resuming a persisted continuable child. The + * continuation manager 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[] + /** 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. + */ + 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 +} +``` + +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 + ## 终态结果:`SubagentResult` 一次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 @@ -144,7 +216,7 @@ interface SubagentStopReasonMap { ## 活跃 run:`SubagentRun` -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。 ```ts type-equiv /** @@ -169,8 +241,10 @@ 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. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `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. */ readonly result: Promise /** @@ -179,15 +253,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * 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. */ - 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 + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` @@ -223,10 +298,21 @@ interface SubagentProvider { * promise rejects. Ownership transfers to the caller only on fulfillment. */ 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 `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 } ``` -`start()` 仅在 run 就绪时 fulfill。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +`start()` 仅在 run 就绪时 fulfill;`resume()` 采用相同的发布与生命周期观察契约。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 80fb096b4f..624e7aab14 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -893,8 +893,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, ): Promise', - jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @returns the existing steered Task or newly started Task.\n */', + signature: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @param signal - caller cancellation; while live delivery awaits admission,\n * abort cancels the shared activation so the wait reaches quiescence.\n * @returns the existing steered Task or newly started Task.\n */', }, { signature: 'registerProvider(provider: SubagentProvider): () => void', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index e2e1b999ab..7ae82932d2 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: 3d5d5e7498b1700c07486cc6e894e72fed681bec -README.zh.md: eb26c79665d387a1e779050dad476d8672f67642 +README.md: c0811eb3bd76543b4a07e7242772e38bd51db67e +README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index af8a2f7b71..c0811eb3bd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,7 @@ Multiple providers may coexist under different names. This lets a deployment exp | `start(name, request)` | Validate requested capabilities and semantic values, 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. | | `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | | `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. | -| `sendMessage(parent, childId, message, source)` | Steer the current activation or start a new Task that cold-resumes the durable child. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | `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, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e202718f81..6d346ee423 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -2,34 +2,38 @@ [English](README.md) | 中文 -subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程中、另一进程中,还是通过未来的传输机制运行。 +subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。 -## 包(package)的角色 +## 包角色 -该系列包把稳定接口与实现、面向模型的工具分开: +该能力族把稳定接口与实现、面向模型的工具分开: | 包 | 角色 | |---|---| -| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型和生命周期事件。 | -| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent。 | -| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 | +| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | +| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 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` 后续操作工具。 | 多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。 ## 服务 API -`SubagentService` 有四个主要操作: +`SubagentService` 有七个主要操作: | 成员 | 含义 | |---|---| -| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 | +| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理启动过程中取得的全部资源。 | +| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 | +| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 | +| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | +| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消正在运行的子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。 +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -42,7 +46,11 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 -运行时功能通过可选方法是否存在来检查能力:`SubagentRun.steer?` 只有在活跃子 agent 的请求 snapshot 接纳消息后才会兑现,并会拒绝而非排队一个未跟踪轮次;`SubagentProvider.resume?` 则重建已持久化且可继续的子 agent。一次运行表示一个可 dispose(资源释放)的 activation,因此刻意不提供冷恢复操作;已释放的运行无法在重启后重建。 +运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。 + +## 持久化描述符 + +该 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`,不进入模型历史,并由仅追加日志跨压缩保留。 ## 委派深度 @@ -52,13 +60,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使启动过程中已取得的资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 +`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。 -`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。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务只会在 `start()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 @@ -66,17 +74,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再对运行执行 dispose(资源释放),然后才返回。后台委派不会改变该 seam;消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 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`。 ## 模型体验 -通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema,以及前台或通用后台结果,同时子 agent 工作上下文只留在子 agent 中。 +通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 +不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **运行时 steering 和延续只是 seam 能力**:当前工具中没有消费 `sendMessage` 和 `resume` 的面向模型消费方。 +- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。 - **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 15bb14bbe4..cd3f7f1f76 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -248,6 +248,8 @@ export class SubagentContinuationManager { * @param childId - the stable child session id. * @param message - the user-role content to deliver. * @param source - caller-supplied attribution retained across either route. + * @param signal - caller cancellation. During live delivery, abort cancels + * the shared activation and rejects only after it reaches quiescence. * @returns whether the message `steered` the existing Task or `started` a new one. */ async sendMessage( @@ -255,13 +257,14 @@ export class SubagentContinuationManager { childId: SessionId, message: ContentBlock[], source: MessageSource, + signal: AbortSignal, ): Promise { this.assertOwnership(childId) const activation = this.activations.get(childId) if (activation !== undefined) { return { route: 'steered', - taskId: await this.steerActivation(activation, parent, childId, message, source), + taskId: await this.steerActivation(activation, parent, childId, message, source, signal), } } return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } @@ -298,6 +301,7 @@ export class SubagentContinuationManager { 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. */ @@ -323,9 +327,23 @@ export class SubagentContinuationManager { '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) + } try { await run.steer(message, source) } 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. @@ -334,10 +352,30 @@ export class SubagentContinuationManager { 'NOT_DELIVERED', { cause: error }, ) + } finally { + signal.removeEventListener('abort', cancelActivation) } return taskId } + /** 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') + } + await activation.done + throw new SubagentError( + `subagent "${childId}" live delivery was cancelled; the message was not delivered`, + 'CANCELLED', + cause === undefined ? undefined : { cause }, + ) + } + /** * Cold-resume a persisted child into a fresh Task-backed activation. The * Task owns its `AbortController` before descriptor lookup: the load, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 8809e6d4ea..902f9bcaea 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -228,6 +228,8 @@ export class SubagentService extends Service { * @param childId - durable child session id. * @param message - user-role content to deliver. * @param source - durable caller attribution. + * @param signal - caller cancellation; while live delivery awaits admission, + * abort cancels the shared activation so the wait reaches quiescence. * @returns the existing steered Task or newly started Task. */ sendMessage( @@ -235,8 +237,9 @@ export class SubagentService extends Service { childId: SessionId, message: ContentBlock[], source: MessageSource, + signal: AbortSignal, ): Promise { - return this.requireContinuations().sendMessage(parent, childId, message, source) + return this.requireContinuations().sendMessage(parent, childId, message, source, signal) } /** diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 1ef761881d..7cf2ca91b8 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -119,14 +119,16 @@ const coordinatorSource = { kind: 'coordinator', senderSessionId: SessionId('parent'), } as const +const testSendSignal = new AbortController().signal function sendMessage( ctx: Context, parent: Agent, childId: SessionId, content: ReturnType, + signal: AbortSignal = testSendSignal, ) { - return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }) + return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal) } describe('SubagentService.startContinuable', () => { @@ -422,6 +424,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('also consider Y'), coordinatorSource, + testSendSignal, ) releaseFirst() const delivered = await delivery @@ -440,6 +443,27 @@ describe('SubagentService.sendMessage', () => { 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)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const controller = new AbortController() + controller.abort('caller already cancelled') + + await expect(sendMessage( + 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) + }) + it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', 'structured_output', { answer: 7 }), @@ -473,6 +497,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('follow-up that terminal policy rejects'), coordinatorSource, + testSendSignal, ) releaseTool.resolve(undefined) await expect(delivery).rejects.toThrow(/message was not delivered/) @@ -495,6 +520,7 @@ describe('SubagentService.sendMessage', () => { started.childId, message('and then?'), coordinatorSource, + testSendSignal, ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml new file mode 100644 index 0000000000..cf3afead31 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.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 packages/subagent/tool-subagent-control/README.md +README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b +README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index c308e11d99..0f1eb7a966 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -1,8 +1,10 @@ # @deepseek-ai/dsh-tool-subagent-control +English | [中文](README.zh.md) + The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. 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 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. 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. ## Model Experience diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md new file mode 100644 index 0000000000..bd140f93f7 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-tool-subagent-control + +[English](README.md) | 中文 + +可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 + +本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。 + +#### Token 影响 + +每个父级请求支付固定的 schema 成本。 + +#### KV Cache 影响 + +前缀保持稳定;schema 不会在运行时改变。 + +### 投递结果 + +#### 模型看到的内容 + +消息加入运行中的激活时返回 `message delivered to running task `;消息启动一次从持久化存储恢复的激活时返回 `message started task continuing subagent `。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。 + +#### Token 影响 + +每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。 +- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。 diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index d95ecb77a9..3e3bc8eff9 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -72,6 +72,7 @@ export function apply(ctx: Context): void { SessionId(args.subagent_id), message, { kind: 'coordinator', senderSessionId: parent.id }, + exec.signal, ) return Promise.resolve(result) }, 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 02c7e6c1a3..c91c657bac 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 @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -34,9 +34,10 @@ async function setup(script: ConstructorParameters[0]) { await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks, {}) await ctx.plugin(tool) - ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + return { ctx, parent, adapter } } function text(result: { content: { type: string; text?: string }[] }): string { @@ -44,9 +45,15 @@ function text(result: { content: { type: string; text?: string }[] }): string { } let calls = 0 -function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) { +function callTool( + ctx: Context, + name: string, + args: unknown, + agent?: unknown, + signal: AbortSignal = testToolSignal, +) { return ctx.tools.execute({ - signal: testToolSignal, + signal, callId: CallId(`call-${++calls}`), name, arguments: args, @@ -114,6 +121,40 @@ describe('dsh-tool-subagent-control', () => { 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 sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents) + ctx.subagents.sendMessage = (agent, childId, message, source, signal) => { + const delivery = sendMessage(agent, childId, message, source, signal) + 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') + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + it('reports a delivery failure as an errored, not-delivered result', async () => { const { ctx, parent } = await setup([]) const result = await callTool(ctx, 'send_message', { From f14121a4c22af96eb9b15dc8a456e9a389ef6e47 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:10:24 +0800 Subject: [PATCH 22/90] refactor(subagent): narrow continuation interface --- ...07-12-agent-scope-runtime-design.i18n.yaml | 6 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 6 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- ...continuable-background-subagents.i18n.yaml | 4 +- ...-07-21-continuable-background-subagents.md | 44 +++--- ...-21-continuable-background-subagents.zh.md | 44 +++--- ...6-merge-subagent-control-service.i18n.yaml | 4 +- ...26-07-26-merge-subagent-control-service.md | 6 +- ...07-26-merge-subagent-control-service.zh.md | 6 +- ...subagent-continuation-operations.i18n.yaml | 6 + ...-named-subagent-continuation-operations.md | 36 +++++ ...med-subagent-continuation-operations.zh.md | 36 +++++ docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 16 +-- docs/cordis-catalog/services.md | 52 +++---- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 85 ++++++++---- docs/core-data-structures/subagent.zh.md | 85 ++++++++---- docs/event-producer-consumer.md | 10 +- packages/compact/compact-basic/src/index.ts | 4 +- .../tests/manual-compact.spec.ts | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 48 +++---- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 3 +- packages/core/session/README.zh.md | 3 +- packages/core/session/src/index.ts | 34 +---- packages/core/session/tests/scoped.spec.ts | 15 +-- .../session-checkpoint-policy/src/index.ts | 4 +- .../tests/coordinator-contract.ts | 4 +- .../tests/persistence.spec.ts | 6 +- packages/subagent/subagent-fork/src/index.ts | 11 +- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 15 ++- .../tests/subagent-inprocess.spec.ts | 19 ++- packages/subagent/subagent-spawn/src/index.ts | 11 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 13 +- packages/subagent/subagent/README.zh.md | 13 +- .../subagent/subagent/src/continuation.ts | 79 +++++++---- packages/subagent/subagent/src/index.ts | 83 ++++++------ packages/subagent/subagent/src/types.ts | 50 ++++--- .../subagent/tests/continuation.spec.ts | 127 ++++++++++++------ .../subagent/subagent/tests/service.spec.ts | 29 ++-- .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 2 +- .../tool-subagent-control/README.zh.md | 2 +- .../tool-subagent-control/src/index.ts | 14 +- .../tests/tool-subagent-control.spec.ts | 10 +- scripts/gen-cordis-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 17 ++- 55 files changed, 669 insertions(+), 441 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md create mode 100644 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index cbaedf3ab9..82b4452be3 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238 -2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +2026-07-12-agent-scope-runtime-design.md: a0a9a90bcac6e8f2ed0e06f3fbccb7b1244da278 +2026-07-12-agent-scope-runtime-design.zh.md: 09912162808f2f71d9ea49892cc61bbbeb1cf172 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 232fc02d66..a0a9a90bca 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -270,7 +270,7 @@ Subagent startup has one ownership transfer. The provider owns partial resources `SubagentStartRequest.signal` is required. Aborting it requests cancellation during startup and after readiness. `SubagentRun.dispose()` also requests cancellation and awaits quiescence. There is no separate public `run.cancel()` channel. -Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise` because the resumed child has the same asynchronous readiness boundary. +Optional `SubagentRun.steer()` supports a live backend that can confirm steering admission. Optional `SubagentProvider.resume()` returns `Promise` because a reconstructed child has the same asynchronous readiness boundary. The service validates provider capabilities and request semantics before calling the provider. A provider rejection cleans any partial resources before the rejection escapes and emits no `subagent/start`/`subagent/end` pair. After fulfillment, the service attaches result observation, emits scoped start, and returns the run. Provider removal prevents later starts but does not revoke a run already accepted by the provider. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 39d558f8cd..0991216280 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -270,7 +270,7 @@ Subagent 启动有一次所有权转移。提供方拥有部分资源直到其 s `SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 -可选的 `sendMessage()` 支持能接受 steering 的活跃后端。可选的 `resume()` 返回 `Promise`,因为恢复的子级有相同的异步就绪边界。 +可选的 `SubagentRun.steer()` 支持能够确认 steering 准入的活跃后端。可选的 `SubagentProvider.resume()` 返回 `Promise`,因为重建的子级有相同的异步就绪边界。 服务在调用提供方之前验证提供方能力和请求语义。提供方拒绝在拒绝逃出之前清理所有部分资源,且不发射 `subagent/start`/`subagent/end` 对。兑现之后,服务附加结果观察、发射作用域 start 并返回 run。提供方移除阻止后续 start,但不撤销提供方已接受的 run。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 3d1140f5cf..39fa3f91b7 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.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-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 6294c84a8fa11e492316f4b69048aa5f477aa04f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +2026-06-21-subagent-capability-seam.md: d47d8fea1b4c03b19891e6af2d5b1d933feeb553 +2026-06-21-subagent-capability-seam.zh.md: e2fce78d0f76892e03136ab374a116152717f848 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 9c17a93751..d47d8fea1b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -43,7 +43,7 @@ A provider exposes `start(request) → Promise`. Fulfillment publis ### Two kinds of optional capability, discovered two ways - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. -- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. +- **Runtime features** are optional methods at their owning seams: confirmed live delivery is `SubagentRun.steer`, while persisted reconstruction is `SubagentProvider.resume`. Method presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flags object can drift from the implementation. ### Fork vs. fresh are separate backends, not a flag diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 6294c84a8f..e2fce78d0f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -43,7 +43,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 两类可选能力,两种发现方式 - **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 -- **运行时功能**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 +- **运行时功能**是在其所属 seam 上定义的可选方法:提供确认语义的在线投递对应 `SubagentRun.steer`,持久化重建对应 `SubagentProvider.resume`。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flags 对象。 ### Fork 与 fresh 是独立后端,而非一个 flag 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 20623c2641..8813e2d10b 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: 4c2cc4ce2538a3d1cf6756168fe4dc1a6448d22a -2026-07-21-continuable-background-subagents.zh.md: 019b623d447781bf254cb241c1b8f1c64fd49c4a +2026-07-21-continuable-background-subagents.md: 0ea085a3eb9c6e661c1f009f338b264c06f14983 +2026-07-21-continuable-background-subagents.zh.md: 93f4c5b8ba4a052c5a6bb6eac3802601eb0797a5 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 4c2cc4ce25..0ea085a3eb 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). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force; references below to the control service describe the internal continuation manager now exposed through `ctx.subagents`. +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. ## Problem @@ -25,39 +25,39 @@ durable child Session 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. +Foreground delegation keeps its one-shot behavior. Continuation covers background in-process spawn and fork children. Each `tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`; configured continuable mode requires the mounted provider's `resume` capability, while a resumable provider may still use one-shot background policy. 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. +`ctx.subagents` is the only public service. Ordinary `start` stays collection-, Task-, and persistence-agnostic: it validates provider capabilities, dispatches one activation, observes run lifecycle, and returns a holder-owned run. An injected internal continuation manager owns stable child ids, descriptor persistence and lookup, Task-backed activation, and routing through `startContinuable` and `followup`; provider start and resume dispatch use private closures after the manager resolves continuation state. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call the intent operations for continuable background work; foreground and one-shot background delegation use ordinary `start`. The globally named model tool is a thin optional adapter in `@deepseek-ai/dsh-tool-subagent-control`, and its presence does not decide whether continuable work starts. 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 required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint 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. +The initial background delegation asks `ctx.subagents` 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 continuation manager's settlement path, 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 required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint failure as its cause; the manager 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](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. +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 continuation manager. 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](../../proposed/feature/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. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. +`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. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks-local` and `@deepseek-ai/dsh-tool-tasks` with the subagent tools. 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. `sendMessage()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. +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. `followup()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. 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. +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 continuation manager 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. +The continuation manager 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 as task `. 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. +For a continuable initial activation, the continuation manager allocates the stable child session id before Task creation and passes it as `SubagentProviderStartRequest.continuation`; in-process spawn and fork publish that exact id instead of allocating one internally. Ordinary `SubagentStartRequest` has no continuation field. The background tool returns canonical `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. 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 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. +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 continuation manager 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 manager 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. +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 `agent/step` and asynchronous prompt assembly succeed, the message is appended, immutable request history is captured, and `step/start` commits; 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. +The continuation manager 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 `followup` 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 }` and forwards its execution signal; the control service requires both the caller-supplied `MessageSource` and cancellation signal. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. 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' }` and its interaction signal. 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. +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentService.followup()`, matching the intent verb on `Agent`. The service 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 }` and forwards `{ source, signal }`; the service requires both facts in one options object. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. 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' }` and its interaction signal. 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_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. @@ -67,19 +67,19 @@ The service result identifies the route as `steered` with the existing Task id o 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. +Human input uses the same `followup` 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 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`](../../../../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 — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives 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 continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`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 — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives 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](../../../../packages/subagent/subagent/src/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()`](../../../../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. `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. +Cold resume cannot depend on an optional method of `SubagentRun`, because that run has been disposed and is not retained across process restart. A run represents one disposable activation and exposes only activation-scoped operations. `SubagentRun.steer?()` names the confirmed live-only capability so it 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 internal continuation manager'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 `SubagentProviderResumeRequest`, including the Task-owned cancellation signal, through a private service closure 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. `SubagentService.followup()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither private provider dispatch 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. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. 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. +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 `SessionStore.flush()` after the child becomes idle and before reading the result; `true` confirms at least one durability listener participated, `false` is a required-checkpoint failure, and rejection carries a listener failure. 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 continuation manager disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. 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. @@ -103,7 +103,7 @@ Task records and active-run associations are process-local. Persistence makes th **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 service-placement alternative was later adopted by the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md), which keeps raw start/resume transport reusable while isolating optional Task and persistence work in an injected internal manager. +**Put control orchestration on `SubagentService`.** This service-placement alternative is the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md); the [intent-operation refinement](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md) keeps provider start/resume dispatch reusable only inside the service while isolating optional Task and persistence work in an injected internal manager. **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. @@ -118,8 +118,8 @@ Task records and active-run associations are process-local. Persistence makes th ## 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. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.sendMessage()`. +- Two callers may still race a stopped child through paths outside the continuation manager. 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 `followup` without a public lifecycle state machine. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.followup()`. - 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. 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 019b623d44..93f4c5b8ba 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)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效;下文所提控制服务,是指现已通过 `ctx.subagents` 公开的内部继续执行管理器。 +本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效。 ## 问题 @@ -25,39 +25,39 @@ durable child Session activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose ``` -前台委派保持一次性行为。继续执行覆盖进程内 spawn 和 fork child。提供方支持从持久化存储恢复后,才能将其 child 标记为可继续——`tool-subagent` 会依据所挂载提供方的 `resume` 功能对其后台路由进行分支——在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 +前台委派保持一次性行为。继续执行覆盖后台的进程内 spawn 和 fork child。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 功能,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 -底层 `ctx.subagents` seam 不感知 child 集合、Task 与持久化。它注册提供方,校验并分发 `start` 或 `resume`,观察 run 生命周期,并返回由持有方负责的 run。`SubagentControlService`(`@deepseek-ai/dsh-subagent-control` 中的 `ctx.subagentControl`)负责管理可继续 child 的稳定 id、持久化描述符并按已知 child id 查找、由 Task 支撑的激活,以及消息路由。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器通过该控制服务处理可继续后台工作;前台一次性委派仍直接调用 `ctx.subagents.start()`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 +`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Task 与持久化:它校验提供方功能、分发一次激活、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、描述符持久化与查找、由 Task 支撑的激活,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器解析继续执行状态后,提供方的 start 与 resume 分发通过私有闭包进行。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 ### Task 与取消的所有权 -初始后台委派请求控制服务启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,调用 `run.dispose()`(经由控制服务的 `settleRun`),然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;控制服务会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 +初始后台委派请求 `ctx.subagents` 启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,通过继续执行管理器的结算路径调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;管理器会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 后续每个轮次都会创建另一个 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](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 +用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过继续执行管理器,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 -如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 -取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`sendMessage()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 -从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但控制服务必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 +从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 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 session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 -对于可继续 child 的初始激活,控制服务会在创建 Task 前分配稳定的 child session id,并通过已完全解析的提供方启动请求(`SubagentStartRequest.continuation`)传递该 id;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。后台工具的确认消息会同时公开两种标识,格式为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的控制操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 +对于可继续 child 的初始激活,继续执行管理器会在创建 Task 前分配稳定的 child session id,并将其作为 `SubagentProviderStartRequest.continuation` 传递;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。普通 `SubagentStartRequest` 不含 continuation 字段。后台工具返回规范的 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 -每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 +每个可继续 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 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/pre-step` 成功后追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/step` 与异步提示词组装成功后,系统追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 -控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 +继续执行管理器不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `followup` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 ### 面向模型的 `send_message` -模型获得一个由 `SubagentControlService.sendMessage()` 支撑的 `send_message(subagent_id, message)` 工具。控制操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发其执行信号;控制服务要求调用方同时提供 `MessageSource` 和取消信号。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 +模型获得一个由 `SubagentService.followup()` 支撑的 `send_message(subagent_id, message)` 工具,与 `Agent` 上的意图动词一致。该服务操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发 `{ source, signal }`;服务要求在一个选项对象中同时提供这两项信息。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 - 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 - 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 @@ -67,19 +67,19 @@ durable child Session 发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 -用户输入使用同一个控制操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个控制服务契约,不建立彼此独立的执行路径。 +用户输入使用同一个 `followup` 操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个服务契约,不建立彼此独立的执行路径。 ### 持久化 child handle 与从持久化存储恢复 -控制服务在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 +继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 版本化描述符([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)包含 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?()`,以免其提供确认语义且仅适用于在线消息的契约与服务编排或面向模型的工具混淆。 +从持久化存储恢复不能依赖 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。`SubagentRun.steer?()` 这一名称明确指代提供确认语义且仅适用于在线消息的功能,以免该功能与服务编排或面向模型的工具混淆。 -`SubagentControlService` 的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它向底层 `SubagentService.resume(provider, request)` 传递完全解析的请求,其中包含由 Task 持有的取消信号;后者只负责检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentControlService.sendMessage()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。底层服务和提供方都不会枚举持久化 child 或关联 Task。 +内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。 -后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终会话 flush;此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,控制服务会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少有一个持久性监听器参与,返回 `false` 表示必需的检查点失败,而拒绝则携带监听器失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 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 写入持久化目录。 @@ -103,7 +103,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 **在已 dispose 的 run 上保留 `resume?()`。** 如果仅为调用 `resume()` 而保留已 dispose 的 `SubagentRun`,旧 run 会同时充当持久化 child handle,而且进程重启后无法重建该对象。由服务分发、提供方重建,可明确表达持久化边界。 -**将控制编排放在 `SubagentService` 上。** 后来的[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md)采用了这一服务放置方案;该方案保持底层 start/resume 传输可复用,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 +**将控制编排放在 `SubagentService` 上。** 这一服务放置方案即[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md);[意图操作细化](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)将提供方 start/resume 分发的复用限制在服务内部,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 **增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 @@ -118,8 +118,8 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可 ## 影响 - 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 -- 两个调用方仍可能通过控制服务外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过控制服务消除重复的 cold resume。 -- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.sendMessage()` 提交用户输入。 +- 两个调用方仍可能通过继续执行管理器外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过 `followup` 消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.followup()` 提交用户输入。 - 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 - 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 - 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 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 fe5d14796a..f28c1f6a8e 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: eb8a76dd4dfc5f06deb67608a67c12e061819286 -2026-07-26-merge-subagent-control-service.zh.md: 6599606634a1933790949e8a66df906a0bb9def0 +2026-07-26-merge-subagent-control-service.md: 84995446939d0f47e008bffb38083b1b6e0706de +2026-07-26-merge-subagent-control-service.zh.md: 7f82555159bfea9e00fa4cc2afdcf30382f3f776 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 eb8a76dd4d..8499544693 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,13 +4,15 @@ 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). + ## Problem Continuable-child orchestration originally lived in a separate `ctx.subagentControl` service above the raw `ctx.subagents` provider seam. That split kept provider dispatch independent of Tasks and persistence, and gave model and human adapters one orchestration contract. In practice the two services described one capability family, every continuable caller needed both, and the provider-bound delegation tool had to infer policy from `provider.resume` and inspect whether the control service and `send_message` tool happened to be loaded. This made sibling plugin presence decide execution semantics and coupled starting continuable work to an optional follow-up surface. ## Decision -`SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. +`SubagentService` is the only public service. It exposes ordinary `start(name, request)`, Task-backed `startContinuable(spec)`, and intent-named `followup(...)`; provider resume dispatch remains private to its continuation manager. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are absent; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class. @@ -35,5 +37,5 @@ Each `@deepseek-ai/dsh-tool-subagent` instance selects `backgroundMode: 'one-sho - The service topology has one public key and one package fewer while raw provider dispatch remains usable without Tasks or persistence. - Continuable mode fails at provider mount when the configured provider lacks `resume`; missing Tasks, Agents, or persistence still fail at the earliest operation that requires them. - Follow-up delivery remains optional. Deployments may start and collect continuable work through Task tools without exposing `send_message`. -- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` and `resume` callers do not need them. +- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` callers do not need them. - Existing continuation races, authorization, durability, cancellation, and settle-then-dispose semantics are unchanged and remain pinned by the migrated `subagent` tests. 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 6599606634..7f82555159 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,13 +4,15 @@ Status: implemented [English](2026-07-26-merge-subagent-control-service.md) | 中文 +公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化。 + ## 问题 可继续 child 的编排最初位于原始 `ctx.subagents` 提供方 seam 之上的独立 `ctx.subagentControl` 服务中。该拆分使提供方分发与 Task 和持久化无关,并为模型与人工适配器提供统一的编排契约。实践中,两个服务属于同一组功能,每个可继续调用方都需要二者,而绑定提供方的委派工具必须根据 `provider.resume` 推断策略,并检查控制服务与 `send_message` 工具是否碰巧已加载。如此一来,配套插件是否存在会决定执行语义,并将可继续工作的启动耦合到可选的后续操作接口。 ## 决策 -`SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)` 和 `resume(name, request)`,并公开 `startContinuable(spec)` 与 `sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 +`SubagentService` 是唯一的公开服务。它公开普通的 `start(name, request)`、由 Task 支撑的 `startContinuable(spec)`,以及按意图命名的 `followup(...)`;提供方的 resume 分发仍封装在其继续执行管理器内部。独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键均不存在;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。 @@ -35,5 +37,5 @@ Status: implemented - 服务拓扑少了一个公开键和一个包,同时底层提供方分发仍可在没有 Task 或持久化时使用。 - 配置的提供方缺少 `resume` 时,可继续模式会在提供方挂载阶段失败;缺少 Task、Agent 或持久化时,仍会在需要它们的最早操作处失败。 - 后续消息投递仍为可选功能。部署可以通过 Task 工具启动并收集可继续工作,而不公开 `send_message`。 -- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 和 `resume` 调用方并不需要它们。 +- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 调用方并不需要它们。 - 现有的继续执行竞态、授权、持久性、取消及先结算再 dispose 的语义均保持不变,并继续由迁移后的 `subagent` 测试固定。 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 new file mode 100644 index 0000000000..5623e559bc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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/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 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 new file mode 100644 index 0000000000..1155e6b2fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -0,0 +1,36 @@ +# Agent Note: Intent-named subagent continuation operations + +Status: implemented + +English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) + +## 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. + +The durability boundary also exposed both `SessionStore.flush()` and `flushRequired()`. They performed the same scoped parallel dispatch and differed only in whether an empty listener snapshot was accepted, so the session interface encoded one consumer's policy as a second operation. + +## Decision + +`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. + +Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. + +`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. + +## Alternatives considered + +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. + +**Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. + +**Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. + +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. + +## Consequences + +- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. +- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. +- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. 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 new file mode 100644 index 0000000000..5f434cd8fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 按意图命名的 subagent 继续执行操作 + +Status: implemented + +[English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 + +## 问题 + +将可继续 child 的编排合并到 `ctx.subagents` 后,提供方分发与调用方意图共存于同一个公开服务中。`resume(name, request)` 接受描述符、已鉴权的 parent、持久化 child id 与激活信号,而只有内部继续执行管理器才能正确解析这些数据。`sendMessage(...)` 暴露的是传输层措辞,而不是 `Agent` 已采用的 `followup` 意图;它还将来源与信号拆成独立参数,扩大了操作接口,而每个调用方都必须以原子方式同时使用二者。 + +持久性边界还同时公开了 `SessionStore.flush()` 与 `flushRequired()`。二者执行相同的作用域内并行分发,唯一差别是是否接受空的监听器快照,因此会话接口将一个消费方的策略编码为第二项操作。 + +## 决策 + +`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 + +调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 + +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 + +## 已考虑的替代方案 + +**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 + +**在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 + +**保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 + +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 + +## 影响 + +- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 +- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 +- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c2158222ee..33bd5777e6 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:25`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:30`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1596,7 +1596,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagent/subagent-spawn/src/index.ts) +Source: [`packages/subagent/subagent-spawn/src/index.ts:25`](../packages/subagent/subagent-spawn/src/index.ts) ## `@deepseek-ai/dsh-system-prompt` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4624b54dec..c7b0734585 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -685,14 +685,12 @@ Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/sr ### `session/flush` — parallel -Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. An empty listener snapshot is accepted by SessionStore.flush and rejected by SessionStore.flushRequired. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. An empty listener - * snapshot is accepted by {@link SessionStore.flush} and rejected by - * {@link SessionStore.flushRequired}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -703,7 +701,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) ## `settings/*` @@ -796,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:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -813,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -828,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:145`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -850,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:156`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 89a4dd5dfa..ca1d6f75fc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1635,22 +1635,11 @@ announce(session: Session): void * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ -async flush(session: Session): Promise - -/** - * Dispatch the same awaited checkpoint as {@link flush}, but reject when its - * scoped listener snapshot is empty. Callers use this operation when success - * requires an installed durability participant rather than optional - * best-effort persistence. - * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when at least one listener participated and every - * listener settled successfully. - * @throws when no listener is registered or any registered listener fails. - */ -async flushRequired(session: Session): Promise +async flush(session: Session): Promise /** * Look up a live session. @@ -1684,7 +1673,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:766`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:764`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1972,17 +1961,18 @@ Named provider registry with raw and Task-backed continuation operations. startContinuable(spec: ContinuableStartSpec): ContinuableStart /** - * Deliver a message to a continuable child by steering its live activation - * or cold-resuming a fresh Task-backed activation. + * 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. * @param childId - durable child session id. - * @param message - user-role content to deliver. - * @param source - durable caller attribution. - * @param signal - caller cancellation; while live delivery awaits admission, - * abort cancels the shared activation so the wait reaches quiescence. + * @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. */ -sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise +followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -2016,23 +2006,11 @@ list(): string[] * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise - -/** - * Resume a persisted continuable child through the named provider's - * `resume` capability, with the same run lifecycle observation as - * {@link start}. The internal continuation manager has already loaded the - * child, folded its descriptor, and authorized the parent; this method owns - * only capability-checked dispatch. - * @param name - the provider recorded in the child's descriptor. - * @param request - the fully resolved resume request. - * @returns the fresh holder-owned run for the resumed activation. - */ -async resume(name: string, request: SubagentResumeRequest): 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) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../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) · [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) -Source: [`packages/subagent/subagent/src/index.ts:198`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:199`](../../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 aaa02e1587..6fd7de2f74 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: 1321429ac9e6280878016601646dd08981ab2b40 -subagent.zh.md: 072b2d2c1635d7c2c59b5a24d2bafc6ee32f8422 +subagent.md: 2dc25dfb14b1506edf7f53f6ce0d8681fefa98c6 +subagent.zh.md: 00f2748ad92ae37b0a2fe2616d9e052f9c4b916f diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 1321429ac9..2dc25dfb14 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -39,8 +39,8 @@ The tool layer builds this request from the model input and its own config; the /** * 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, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -89,23 +89,36 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` 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` +Providers receive a separate resolved shape. Raw `SubagentService.start()` clears continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. -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 resolved start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.sendMessage()` loads and authorizes a stopped child before dispatching a fully resolved resume request through the raw `resume()` operation, or steers its live activation. 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 `sendMessage()` reports whether the message `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal; 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. +```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 provider resume + +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. ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -118,8 +131,33 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + 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. */ @@ -131,14 +169,13 @@ interface SubagentContinuation { ```ts type-equiv /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * 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`. */ -interface SubagentResumeRequest { +interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -295,22 +332,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): 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 `resume` dispatch and continuable starts on + * — 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. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } ``` -`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. +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. ## 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 072b2d2c16..00f2748ad9 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -39,8 +39,8 @@ 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, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -89,23 +89,36 @@ interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` 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` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 -## 可继续子 agent:`SubagentContinuation` 与 `SubagentResumeRequest` +提供方会接收单独的已解析请求类型。直接调用 `SubagentService.start()` 会清除继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 -**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过已解析的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.sendMessage()` 会先加载并授权已停止的子 agent,再通过底层 `resume()` 操作分发完全解析的恢复请求,或引导其实时激活。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`sendMessage()` 则报告消息是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都会提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 +```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 会话,由一系列由 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' }` 及其交互信号。 ```ts type-equiv /** Attribution for a model coordinator's follow-up to one of its children. */ @@ -118,8 +131,33 @@ interface CoordinatorMessageSource { ```ts type-equiv /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + 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. */ @@ -131,14 +169,13 @@ interface SubagentContinuation { ```ts type-equiv /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * 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`. */ -interface SubagentResumeRequest { +interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -297,22 +334,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): 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 `resume` dispatch and continuable starts on + * — 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. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } ``` -`start()` 仅在 run 就绪时 fulfill;`resume()` 采用相同的发布与生命周期观察契约。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fef8b22088..33f004e102 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,14 +37,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `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:165`](../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:139`](../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:145`](../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:156`](../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: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) | | `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/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f64e05280d..ebef4648df 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -398,7 +398,9 @@ export class BasicCompactService extends CompactService { { owner: null, stability: 'selected-span', - flush: () => this.ctx.sessions.flush(agent.session), + flush: async () => { + await this.ctx.sessions.flush(agent.session) + }, }, signal, ) diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index aadf50f306..6ce3e18f2a 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -217,7 +217,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactService; flushe let flushes = 0 vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => { flushes += 1 - return Promise.resolve() + return Promise.resolve(false) }) return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes } } @@ -730,7 +730,7 @@ describe('compactNow transaction and failure classification', () => { const { ctx, compact } = detachedService() const controller = new AbortController() const reason = new Error('cancelled during flush') - const flushGate = Promise.withResolvers() + const flushGate = Promise.withResolvers() const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise) const session = closedConversation(2) let released = 0 @@ -750,7 +750,7 @@ describe('compactNow transaction and failure classification', () => { expect(settled).toBe(false) expect(released).toBe(0) - flushGate.resolve(undefined) + flushGate.resolve(false) await expect(running).rejects.toBe(reason) expect(released).toBe(1) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 624e7aab14..224bf25e0b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -743,12 +743,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/** Emit `session/created` exactly once for an {@link enter}ed session (with\n * the carrier {@link enter} captured). Separate from {@link enter} so the\n * caller can yield the detach disposer first (rollback safety — see\n * {@link enter}).\n * @param session - the entered session to announce to listeners.\n * @throws if the session is not live or its announcement already began,\n * including a reentrant call from a creation listener. */', }, { - signature: 'async flush(session: Session): Promise', - jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', - }, - { - signature: 'async flushRequired(session: Session): Promise', - jsDoc: '/**\n * Dispatch the same awaited checkpoint as {@link flush}, but reject when its\n * scoped listener snapshot is empty. Callers use this operation when success\n * requires an installed durability participant rather than optional\n * best-effort persistence.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when at least one listener participated and every\n * listener settled successfully.\n * @throws when no listener is registered or any registered listener fails.\n */', + signature: 'async flush(session: Session): Promise', + jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', }, { signature: 'get(id: SessionId): Session | undefined', @@ -893,8 +889,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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: 'sendMessage( parent: Agent, childId: SessionId, message: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Deliver a message to a continuable child by steering its live activation\n * or cold-resuming a fresh Task-backed activation.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param message - user-role content to deliver.\n * @param source - durable caller attribution.\n * @param signal - caller cancellation; while live delivery awaits admission,\n * abort cancels the shared activation so the wait reaches quiescence.\n * @returns the existing steered Task or newly started Task.\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: 'registerProvider(provider: SubagentProvider): () => void', @@ -912,10 +908,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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 */', }, - { - signature: 'async resume(name: string, request: SubagentResumeRequest): Promise', - jsDoc: '/**\n * Resume a persisted continuable child through the named provider\'s\n * `resume` capability, with the same run lifecycle observation as\n * {@link start}. The internal continuation manager has already loaded the\n * child, folded its descriptor, and authorized the parent; this method owns\n * only capability-checked dispatch.\n * @param name - the provider recorded in the child\'s descriptor.\n * @param request - the fully resolved resume request.\n * @returns the fresh holder-owned run for the resumed activation.\n */', - }, ], }, { @@ -1414,7 +1406,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. An empty listener\n * snapshot is accepted by {@link SessionStore.flush} and rejected by\n * {@link SessionStore.flushRequired}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { @@ -1805,7 +1797,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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 label: string;\n readonly request: Omit;\n}', }, { name: 'CreateAgentOptions', @@ -2355,10 +2347,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SearchResultView', declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', }, - { - name: 'SendMessageResult', - declaration: 'export type SendMessageResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', - }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2695,25 +2683,37 @@ export const TYPE_API: readonly TypeApiEntry[] = [ 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: SubagentStartRequest): Promise;\n resume?(request: SubagentResumeRequest): Promise;\n}', + 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}', }, { name: 'SubagentResult', declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, - { - name: 'SubagentResumeRequest', - declaration: 'export interface SubagentResumeRequest {\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: '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}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n readonly continuation?: SubagentContinuation;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 88ac2bd405..00260f89cb 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: 59e8694a957e9742a22662766d671dc2145c44e3 -README.zh.md: 7618bc8f3a9146a4fc5afbfb19317deef7f13068 +README.md: 4730cac913e949d642d049a5c53ab2dd47e10627 +README.zh.md: 12aa1625d7b0568196cd788c2a25cfb869d8780d diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 59e8694a95..4730cac913 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -13,8 +13,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. -- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. -- `ctx.sessions.flushRequired(session)` uses the same dispatch but also rejects an empty scoped listener snapshot. Callers use it when success requires an installed durability participant rather than optional best-effort persistence. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 7618bc8f3a..12aa1625d7 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -13,8 +13,7 @@ ### 公共 API - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 -- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 -- `ctx.sessions.flushRequired(session)` 沿用相同的分发逻辑,但也会拒绝空的作用域监听器快照。若成功要求已安装的持久性参与方介入,而不是采用可选的尽力持久化,调用方应使用此方法。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 55c3a11e83..ad63179156 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -93,9 +93,7 @@ declare module 'cordis' { 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. An empty listener - * snapshot is accepted by {@link SessionStore.flush} and rejected by - * {@link SessionStore.flushRequired}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -970,35 +968,14 @@ export class SessionStore extends Service { * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ - async flush(session: Session): Promise { - await this.dispatchFlush(session, false) - } - - /** - * Dispatch the same awaited checkpoint as {@link flush}, but reject when its - * scoped listener snapshot is empty. Callers use this operation when success - * requires an installed durability participant rather than optional - * best-effort persistence. - * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when at least one listener participated and every - * listener settled successfully. - * @throws when no listener is registered or any registered listener fails. - */ - async flushRequired(session: Session): Promise { - await this.dispatchFlush(session, true) - } - - /** Dispatch one optional or required flush listener snapshot. */ - private async dispatchFlush(session: Session, requireListener: boolean): Promise { + async flush(session: Session): Promise { const { carrier } = this.liveEntryFor(session) const callbackArgs: unknown[] = [session] const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]) - if (requireListener && callbacks.length === 0) { - throw new Error(`session "${session.id}" required durability checkpoint has no registered listener`) - } const results = await Promise.allSettled(callbacks.map((callback) => { try { return callback(...callbackArgs) @@ -1011,6 +988,7 @@ export class SessionStore extends Service { })) const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') if (failure !== undefined) throw failure.reason + return callbacks.length > 0 } /** Return the exact live entry; detached/prepared objects reject. */ diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index a524d9cb86..ffa436bc8b 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -84,25 +84,16 @@ describe('sessions.flush()', () => { const ctx = await mount() const session = ctx.sessions.create() - await expect(ctx.sessions.flush(session)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(session)).resolves.toBe(false) }) - it('rejects a required flush with no listeners', async () => { - const ctx = await mount() - const session = ctx.sessions.create() - - await expect(ctx.sessions.flushRequired(session)).rejects.toThrow( - `session "${session.id}" required durability checkpoint has no registered listener`, - ) - }) - - it('completes a required flush when a listener succeeds', async () => { + it('reports a participating listener after it succeeds', async () => { const ctx = await mount() const session = ctx.sessions.create() const flushed: Session[] = [] ctx.on('session/flush', current => void flushed.push(current)) - await ctx.sessions.flushRequired(session) + await expect(ctx.sessions.flush(session)).resolves.toBe(true) expect(flushed).toEqual([session]) }) diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 9108a29c14..cf5722ff29 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,5 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) + ctx.on('agent/step', async (agent): Promise => { + await ctx.sessions.flush(agent.session) + }) } diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index d8ee35f4b7..971eed79c6 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -719,7 +719,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(reuse)).resolves.toBe(true) reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(reuse) @@ -796,7 +796,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(live)).resolves.toBe(true) const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) // Seeded 0-5 plus the constructor's end-seed event at 6. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index bca32438f4..eb80683431 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -279,7 +279,7 @@ describe('PersistenceCoordinator eager writes', () => { const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)] appendGate.resolve(true) - await expect(Promise.all(barriers)).resolves.toEqual([undefined, undefined]) + await expect(Promise.all(barriers)).resolves.toEqual([true, true]) expect(backend.appendAttempts).toBe(2) expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) } finally { @@ -353,7 +353,7 @@ describe('PersistenceCoordinator stored identity', () => { expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) const resumed = ctx.sessions.create(id, { seed: loaded.events, meta: loaded.meta }) - await expect(ctx.sessions.flush(resumed)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(resumed)).resolves.toBe(true) } finally { loadGate.resolve(true) await fiber.dispose() @@ -592,7 +592,7 @@ describe('PersistenceCoordinator retirement', () => { const reuseFlush = ctx.sessions.flush(reuse) loadGate.resolve(true) - await expect(reuseFlush).resolves.toBeUndefined() + await expect(reuseFlush).resolves.toBe(true) } finally { loadGate.resolve(true) await backendFiber.dispose() diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 37e2556d44..8461795120 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -11,7 +11,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} from '@deepseek-ai/dsh-subagent' import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-fork' @@ -59,7 +64,7 @@ class ForkProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed @@ -68,7 +73,7 @@ class ForkProvider implements SubagentProvider { }) } - resume(request: SubagentResumeRequest) { + 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. diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 89d9134fcb..25b886b635 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: 525760ccc413bb46ca5ea3a37e610a3ff58b8068 -README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e +README.md: 8d266e93021285e27e7819386a4de9c33492a796 +README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 525760ccc4..8d266e9302 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 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.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and 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. +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. 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 4d02e2bb89..79450a32a7 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 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.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 +5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 5e6abdda06..38fd418f39 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -15,10 +15,10 @@ import { createUserMessage, errorChain, type ContentBlock, type MessageSource } import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentDescriptorData, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, - SubagentStartRequest, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve @@ -108,7 +108,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto * @returns a ready holder-owned run. */ export async function startInProcessRun( - request: SubagentStartRequest, + request: SubagentProviderStartRequest, options: InProcessRunOptions, ): Promise { assertSubagentMaxDepth(request.maxDepth) @@ -197,10 +197,10 @@ export async function startInProcessRun( * (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 low-level service. + * @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: SubagentResumeRequest): Promise { +export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise { if (request.signal.aborted) throw prePublicationAbort() const descriptor = request.descriptor const agentOptions: AgentOptions = { @@ -269,7 +269,10 @@ function driveTurn( await child.whenIdle() if (durability === 'required') { try { - await child.ctx.sessions.flushRequired(child.session) + 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( diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d052a3a1e1..530720596f 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -73,6 +73,21 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => { + const { ctx } = await setup([textResponse('driver answer')]) + const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' }) + const run = await startInProcessRun({ + ...request(parent), + agentOptions: { provider: 'mock', model: 'mock' }, + }, {}) + + const child = ctx.agents.get(run.id)! + expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' }) + expect(child.session.header.cwd).toBe('/workspace') + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + await run.dispose() + }) + it('rejects a continuable child when no durability listener is registered', async () => { const { parent } = await setup([textResponse('driver answer')]) @@ -346,9 +361,9 @@ describe('startInProcessRun', () => { acceptsNextStep: false, ctx: { sessions: { - flushRequired: () => { + flush: () => { flushes++ - return Promise.resolve() + return Promise.resolve(true) }, }, } as unknown as Context, diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 22594fef2e..0080c31521 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -8,7 +8,12 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} from '@deepseek-ai/dsh-subagent' import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' @@ -40,14 +45,14 @@ class SpawnProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { // 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: SubagentResumeRequest) { + 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) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7ae82932d2..0d8b499482 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: c0811eb3bd76543b4a07e7242772e38bd51db67e -README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d +README.md: a484352c486c067058bef806bad3bcd7623cf6cc +README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index c0811eb3bd..a484352c48 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,19 +21,18 @@ Multiple providers may coexist under different names. This lets a deployment exp ## Service API -`SubagentService` has seven main operations: +`SubagentService` has six main 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 requested capabilities and semantic values, 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. | -| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. | +| `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. | -| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | +| `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. | -`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, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability. +`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. 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. @@ -60,13 +59,13 @@ The seam owns the depth vocabulary shared by implementations and consumers: the ## 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. +`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. `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. 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`. -The service emits `subagent/start` only after `start()` or `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. +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. 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 6d346ee423..9a750d5dfa 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -21,19 +21,18 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 服务 API -`SubagentService` 有七个主要操作: +`SubagentService` 有六个主要操作: | 成员 | 含义 | |---|---| | `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 | -| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 | +| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 | | `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | -| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | +| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。 +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -60,13 +59,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。 +`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。 `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()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index cd3f7f1f76..1f4aa54a0c 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -21,8 +21,13 @@ 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 { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts' -import type { SubagentService } from './index.ts' +import type { + SubagentProviderResumeRequest, + SubagentProviderStartRequest, + SubagentResult, + SubagentRun, + SubagentStartRequest, +} from './types.ts' import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' import { SubagentError } from './error.ts' @@ -50,10 +55,10 @@ export interface ContinuableStartSpec { * durable descriptor, then supplies the Task-owned cancellation signal and * `continuation` itself. */ - readonly request: Omit + readonly request: Omit } -/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */ +/** Identities returned by a continuable start. */ export interface ContinuableStart { /** The durable child session id, stable across activations. */ readonly childId: SessionId @@ -62,16 +67,29 @@ export interface ContinuableStart { } /** - * How {@link SubagentContinuationManager.sendMessage} delivered a message: + * Options for following up with one continuable child. + */ +export interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + 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 message. Failure is an exception, never a result — - * an undelivered message throws. + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. */ -export type SendMessageResult = +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 + /** * One child's current process-local activation: its Task and, after provider * publication, its run. Installed before any provider or persistence await @@ -98,7 +116,7 @@ interface ActiveActivation { * @param result - child terminal result. * @returns outcome for the `ctx.tasks` registration. */ -export function runOutcome(result: SubagentResult): TaskOutcome { +function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) { case 'completed': return { status: 'completed', output: finalText(result.output) } @@ -154,7 +172,7 @@ function finalText(blocks: ContentBlock[]): string { /** * The continuable-subagent orchestration service. Tool schema and UI adapters * are consumers of this one contract: parent and human messages route through - * {@link sendMessage} and share one activation result and cancellation + * {@link followup} and share one activation result and cancellation * boundary, while foreground one-shot delegation keeps calling * `ctx.subagents.start()` directly. */ @@ -164,7 +182,8 @@ export class SubagentContinuationManager { constructor( private readonly ctx: Context, - private readonly subagents: SubagentService, + private readonly startProvider: StartProvider, + private readonly resumeProvider: ResumeProvider, ) { // 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. @@ -224,7 +243,7 @@ export class SubagentContinuationManager { ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) const taskId = this.startActivation(childId, spec.label, request.parent, signal => - this.subagents.start(spec.provider, { + this.startProvider(spec.provider, { ...request, signal, continuation: { sessionId: childId, descriptor }, @@ -233,7 +252,7 @@ export class SubagentContinuationManager { } /** - * Deliver one message to a known continuable child: steer its running + * 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 @@ -246,28 +265,36 @@ export class SubagentContinuationManager { * @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. - * @param message - the user-role content to deliver. - * @param source - caller-supplied attribution retained across either route. - * @param signal - caller cancellation. During live delivery, abort cancels - * the shared activation and rejects only after it reaches quiescence. - * @returns whether the message `steered` the existing Task or `started` a new one. + * @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. */ - async sendMessage( + async followup( parent: Agent, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { + 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, message, source, signal), + taskId: await this.steerActivation( + activation, + parent, + childId, + content, + options.source, + options.signal, + ), } } - return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) } + return { + route: 'started', + taskId: this.resumeActivation(parent, childId, content, options.source), + } } /** @@ -422,7 +449,7 @@ export class SubagentContinuationManager { 'NOT_RESUMABLE', ) } - return this.subagents.resume(descriptor.provider, { + return this.resumeProvider({ sessionId: childId, prompt: message, source, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 902f9bcaea..508388e4f5 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,11 +13,11 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Raw `start` and `resume` remain collection-agnostic provider dispatch. - * When `ctx.tasks` and `ctx.agents` are available, the same service also binds - * an internal continuation manager for durable child ids, descriptor lookup, - * Task-backed activations, and steer-or-resume delivery. Persistence remains - * optional and is required only when a continuation operation is called. + * 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. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -32,14 +32,15 @@ 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, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, SubagentStartRequest, } from './types.ts' @@ -49,7 +50,8 @@ import SubagentContinuationManager from './continuation.ts' import type { ContinuableStart, ContinuableStartSpec, - SendMessageResult, + SubagentFollowupOptions, + SubagentFollowupResult, } from './continuation.ts' export * from './out-of-process.ts' @@ -58,8 +60,9 @@ export type { SubagentCapabilities, SubagentContinuation, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, - SubagentResumeRequest, SubagentRun, SubagentStartRequest, SubagentStopReason, @@ -72,15 +75,13 @@ export { } from './descriptor.ts' export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' export { SubagentError } from './error.ts' -export { - runOutcome, - settleRun, -} from './continuation.ts' +export { settleRun } from './continuation.ts' export type { ContinuableStart, ContinuableStartSpec, CoordinatorMessageSource, - SendMessageResult, + SubagentFollowupOptions, + SubagentFollowupResult, } from './continuation.ts' declare module '@deepseek-ai/dsh-agent' { @@ -202,7 +203,11 @@ export class SubagentService extends Service { constructor(ctx: Context) { super(ctx, 'subagents') ctx.inject(['tasks', 'agents'], (childCtx: Context) => { - const manager = new SubagentContinuationManager(childCtx, this) + const manager = new SubagentContinuationManager( + childCtx, + (name, request) => this.startProvider(name, request), + request => this.resumeProvider(request), + ) this.continuations = manager childCtx.effect(() => () => { /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ @@ -222,24 +227,24 @@ export class SubagentService extends Service { } /** - * Deliver a message to a continuable child by steering its live activation - * or cold-resuming a fresh Task-backed activation. + * 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. * @param childId - durable child session id. - * @param message - user-role content to deliver. - * @param source - durable caller attribution. - * @param signal - caller cancellation; while live delivery awaits admission, - * abort cancels the shared activation so the wait reaches quiescence. + * @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. */ - sendMessage( + followup( parent: Agent, childId: SessionId, - message: ContentBlock[], - source: MessageSource, - signal: AbortSignal, - ): Promise { - return this.requireContinuations().sendMessage(parent, childId, message, source, signal) + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + return this.requireContinuations().followup(parent, childId, content, options) } /** @@ -294,6 +299,16 @@ export class SubagentService extends Service { * @returns the ready holder-owned run. */ async start(name: string, request: SubagentStartRequest): Promise { + // A provider request is structurally assignable to the caller shape. Clear + // its wider field so only startContinuable can supply service-owned state. + return this.startProvider(name, { ...request, continuation: undefined }) + } + + /** Validate and dispatch one ordinary or service-resolved provider start. */ + private async startProvider( + name: string, + request: SubagentProviderStartRequest, + ): Promise { const provider = this.expectProvider(name) this.assertCapabilities(provider, request) assertSubagentMaxDepth(request.maxDepth) @@ -308,17 +323,9 @@ export class SubagentService extends Service { return this.observeRun(name, request.parent, await provider.start(request)) } - /** - * Resume a persisted continuable child through the named provider's - * `resume` capability, with the same run lifecycle observation as - * {@link start}. The internal continuation manager has already loaded the - * child, folded its descriptor, and authorized the parent; this method owns - * only capability-checked dispatch. - * @param name - the provider recorded in the child's descriptor. - * @param request - the fully resolved resume request. - * @returns the fresh holder-owned run for the resumed activation. - */ - async resume(name: string, request: SubagentResumeRequest): Promise { + /** Dispatch one authorized provider resume and observe its run lifecycle. */ + private async resumeProvider(request: SubagentProviderResumeRequest): Promise { + const name = request.descriptor.provider const provider = this.expectProvider(name) if (provider.resume === undefined) { throw new SubagentError( diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 50922b183e..da75ae67cc 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -43,8 +43,8 @@ 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, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ export interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -93,20 +93,29 @@ export interface SubagentStartRequest { * persona (strict `{{…}}` interpolation against the registered variables). */ readonly persona?: string - /** - * Continuable-child intent, resolved by `ctx.subagents` 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 } /** - * The resolved continuable-child identity and durable composition record a - * continuation caller attaches to a start request. + * 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. + */ +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. */ @@ -116,14 +125,13 @@ export interface SubagentContinuation { } /** - * What a caller asks for when resuming a persisted continuable child. The - * continuation manager loads the child log, folds and authorizes its descriptor, - * and passes this fully resolved request to - * {@link SubagentService.resume}, which dispatches to + * 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 SubagentResumeRequest { +export interface SubagentProviderResumeRequest { /** The persisted child session id to resume. */ readonly sessionId: SessionId /** The follow-up message that starts the resumed activation's turn. */ @@ -257,16 +265,16 @@ export interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): 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 `resume` dispatch and continuable starts on + * — 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. */ - resume?(request: SubagentResumeRequest): Promise + resume?(request: SubagentProviderResumeRequest): Promise } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7cf2ca91b8..24392d847e 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -18,7 +18,6 @@ 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 SubagentService, { - runOutcome, settleRun, SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -121,14 +120,17 @@ const coordinatorSource = { } as const const testSendSignal = new AbortController().signal -function sendMessage( +function followup( ctx: Context, parent: Agent, childId: SessionId, content: ReturnType, signal: AbortSignal = testSendSignal, ) { - return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal) + return ctx.subagents.followup(parent, childId, content, { + source: { kind: 'user' }, + signal, + }) } describe('SubagentService.startContinuable', () => { @@ -145,6 +147,24 @@ describe('SubagentService.startContinuable', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) + it('fails a continuable Task before dispatch when its provider has no resume capability', async () => { + const { ctx, parent } = await setup([]) + const start = vi.fn(async () => { throw new Error('must not dispatch') }) + ctx.subagents.registerProvider({ + name: 'one-shot', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + 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') + expect(start).not.toHaveBeenCalled() + }) + it('fails the Task when persistence detaches before the activation completes', async () => { const releaseResponse = Promise.withResolvers() const adapter = new GatedAdapter([ @@ -270,7 +290,7 @@ describe('SubagentService.startContinuable', () => { expect(snapshot.status).toBe('failed') expect(snapshot.detail).toContain('maxDepth') // The unmaterialized child id is reported unavailable on later use. - const followUp = await sendMessage(ctx, parent, started.childId, message('hello?')) + 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') @@ -313,7 +333,22 @@ describe('SubagentService.startContinuable', () => { }) }) -describe('SubagentService.sendMessage', () => { +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<{ @@ -341,14 +376,14 @@ describe('SubagentService.sendMessage', () => { await waitPublishedRun(ctx, started.childId) expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) - await expect(sendMessage(ctx, parent, started.childId, message('join'))) + 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 = sendMessage(ctx, parent, started.childId, message('after terminal')).then( + terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then( () => undefined, (error: unknown) => { terminalDeliveryError = error @@ -390,7 +425,7 @@ describe('SubagentService.sendMessage', () => { const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) await waitPublishedRun(ctx, started.childId) - await expect(sendMessage(ctx, parent, started.childId, message('join'))) + 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) @@ -419,12 +454,11 @@ describe('SubagentService.sendMessage', () => { }, 5) }) - const delivery = ctx.subagents.sendMessage( + const delivery = ctx.subagents.followup( parent, started.childId, message('also consider Y'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) releaseFirst() const delivered = await delivery @@ -450,7 +484,7 @@ describe('SubagentService.sendMessage', () => { const controller = new AbortController() controller.abort('caller already cancelled') - await expect(sendMessage( + await expect(followup( ctx, parent, started.childId, @@ -492,12 +526,11 @@ describe('SubagentService.sendMessage', () => { }) await startedTool.promise - const delivery = ctx.subagents.sendMessage( + const delivery = ctx.subagents.followup( parent, started.childId, message('follow-up that terminal policy rejects'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) releaseTool.resolve(undefined) await expect(delivery).rejects.toThrow(/message was not delivered/) @@ -515,12 +548,11 @@ describe('SubagentService.sendMessage', () => { await waitTerminal(ctx, started.taskId, parent) expect(ctx.agents.get(started.childId)).toBeUndefined() - const followUp = await ctx.subagents.sendMessage( + const followUp = await ctx.subagents.followup( parent, started.childId, message('and then?'), - coordinatorSource, - testSendSignal, + { source: coordinatorSource, signal: testSendSignal }, ) expect(followUp.route).toBe('started') expect(followUp.taskId).not.toBe(started.taskId) @@ -559,7 +591,7 @@ describe('SubagentService.sendMessage', () => { expect(descriptor?.data.persona).toBe('You are the resumable child.') expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) - const followUp = await sendMessage(ctx, parent, started.childId, message('continue')) + 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. @@ -588,7 +620,7 @@ describe('SubagentService.sendMessage', () => { parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) await parent.whenIdle() - const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) + 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. @@ -604,7 +636,7 @@ describe('SubagentService.sendMessage', () => { 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 sendMessage(ctx, parent, started.childId, message('go on')) + const followUp = await followup(ctx, parent, started.childId, message('go on')) const childAgents: Agent[] = [] const stop = ctx.on('agent/created', (agent: Agent) => { @@ -624,7 +656,7 @@ describe('SubagentService.sendMessage', () => { const started = ctx.subagents.startContinuable(startSpec(otherParent)) await waitTerminal(ctx, started.taskId, otherParent) - const attempt = await sendMessage(ctx, parent, started.childId, message('mine now')) + 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') @@ -643,7 +675,7 @@ describe('SubagentService.sendMessage', () => { await handle.agent.whenIdle() await handle.dispose() - const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?')) + 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( @@ -653,9 +685,9 @@ describe('SubagentService.sendMessage', () => { it('derives fallback and bounded labels for resumed activations', async () => { const { ctx, parent } = await setup([]) - const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' ')) + const blank = await followup(ctx, parent, SessionId('blank-child'), message(' ')) const longText = 'x'.repeat(100) - const long = await sendMessage(ctx, parent, SessionId('long-child'), message(longText)) + 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)}…`) @@ -673,9 +705,9 @@ describe('SubagentService.sendMessage', () => { meta: { parentSession: parent.id }, agentOptions: { provider: 'mock', model: 'mock' }, }) - await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(SubagentError) - await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello'))) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) .rejects.toThrow(/outside continuation ownership.*not delivered/) await handle.dispose() }) @@ -686,9 +718,10 @@ describe('SubagentService.sendMessage', () => { const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) let releaseDispose!: () => void const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) - const realStart = ctx.subagents.start.bind(ctx.subagents) - ctx.subagents.start = async (name, request) => { - const run = await realStart(name, request) + 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, @@ -716,13 +749,13 @@ describe('SubagentService.sendMessage', () => { // Confirmed steering finds the settled child, fails loud, and does NOT start // a cold resume within this call. - await expect(sendMessage(ctx, parent, started.childId, message('too late?'))) + 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 sendMessage(ctx, parent, started.childId, message('retry')) + const retry = await followup(ctx, parent, started.childId, message('retry')) expect(retry.route).toBe('started') await waitTerminal(ctx, retry.taskId, parent) }) @@ -731,7 +764,7 @@ describe('SubagentService.sendMessage', () => { 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 sendMessage(ctx, parent, started.childId, message('more')) + 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/) }) @@ -750,7 +783,7 @@ describe('SubagentService.sendMessage', () => { return realLoad(id) } - const followUp = await sendMessage(ctx, parent, started.childId, message('follow up')) + 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) @@ -772,11 +805,11 @@ describe('SubagentService.sendMessage', () => { return realLoad(id) } - const first = await sendMessage(ctx, parent, started.childId, message('first follow-up')) + 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(sendMessage(ctx, parent, started.childId, message('second follow-up'))) + await expect(followup(ctx, parent, started.childId, message('second follow-up'))) .rejects.toThrow(/not delivered/) releaseLoad() const snapshot = await waitTerminal(ctx, first.taskId, parent) @@ -830,15 +863,21 @@ describe('service disposal with live activations', () => { }) describe('outcome mapping helpers', () => { - it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => { + 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' }] - expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' }) - expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' }) - expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' }) - expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' }) - expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' }) - // Merge-extensible: an unknown reason is failed-with-detail, never success. - expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' }) + 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 () => { diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 90128302c5..9a274abe22 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -12,6 +12,7 @@ import SubagentService, { assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, + type SubagentProviderStartRequest, type SubagentResult, type SubagentRun, type SubagentStartRequest, @@ -37,6 +38,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 + lastRequest: SubagentProviderStartRequest | undefined constructor( readonly name: string, @@ -47,8 +49,9 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: SubagentStartRequest): Promise { + async start(request: SubagentProviderStartRequest): Promise { this.startCount += 1 + this.lastRequest = request return { id: SessionId(`child:${this.name}:${request.parent.id}`), localAgent: undefined, @@ -102,27 +105,23 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('rejects continuable start and resume when the provider has no resume capability', async () => { + it('keeps provider continuation state out of raw start and exposes no raw resume operation', async () => { const { subagents } = await service() - subagents.registerProvider(new StubProvider('one-shot')) + const provider = new StubProvider('one-shot') + subagents.registerProvider(provider) const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) const sessionId = SessionId('continuable-child') const parent = fakeParent() const signal = new AbortController().signal - await expect(subagents.start('one-shot', baseRequest({ - parent, - signal, + const providerRequest: SubagentProviderStartRequest = { + ...baseRequest({ parent, signal }), continuation: { sessionId, descriptor }, - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) - await expect(subagents.resume('one-shot', { - sessionId, - prompt: [{ type: 'text', text: 'continue' }], - source: { kind: 'user' }, - parent, - signal, - descriptor, - })).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' }) + } + await subagents.start('one-shot', providerRequest) + + expect(provider.lastRequest?.continuation).toBeUndefined() + expect('resume' in subagents).toBe(false) }) it('rejects Task-backed continuation operations when their runtime services are absent', async () => { diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index cf3afead31..fc7ab47339 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: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b -README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb +README.md: 44fbd44b035ce283e404c491d9fa143a08b71127 +README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 0f1eb7a966..44fbd44b03 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. 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 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. diff --git a/packages/subagent/tool-subagent-control/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md index bd140f93f7..3fa1d1e543 100644 --- a/packages/subagent/tool-subagent-control/README.zh.md +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 +可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 3e3bc8eff9..af85457262 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -1,6 +1,6 @@ /** * The globally named `send_message` tool: a thin model-facing adapter over - * `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its + * `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. @@ -60,21 +60,23 @@ export function apply(ctx: Context): void { : `message started task ${value.taskId} continuing subagent ${args.subagent_id}`, }], }, - execute(args, exec) { + async execute(args, exec) { const parent = exec.agent if (!parent) { // Non-agent callers have no session to authorize Task access with. throw new Error('send_message requires a calling agent (exec.agent was undefined)') } const message: ContentBlock[] = [{ type: 'text', text: args.message }] - const result = ctx.subagents.sendMessage( + const result = await ctx.subagents.followup( parent, SessionId(args.subagent_id), message, - { kind: 'coordinator', senderSessionId: parent.id }, - exec.signal, + { + source: { kind: 'coordinator', senderSessionId: parent.id }, + signal: exec.signal, + }, ) - return Promise.resolve(result) + return result }, })) } 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 c91c657bac..b035fa1127 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 @@ -106,9 +106,9 @@ describe('dsh-tool-subagent-control', () => { // 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.sendMessage = async (agent, _childId, message, messageSource) => { + ctx.subagents.followup = async (agent, _childId, message, options) => { steered = (message[0] as { text: string }).text - source = messageSource + source = options.source return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } } const result = await callTool(ctx, 'send_message', { @@ -130,9 +130,9 @@ describe('dsh-tool-subagent-control', () => { }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() - const sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents) - ctx.subagents.sendMessage = (agent, childId, message, source, signal) => { - const delivery = sendMessage(agent, childId, message, source, signal) + 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 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c8aeb86a39..f7ab385afd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -163,9 +163,11 @@ export const LINK_MAP: Readonly> = { ContinuableStart: 'subagent.md', ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', - SendMessageResult: 'subagent.md', + SubagentFollowupOptions: 'subagent.md', + SubagentFollowupResult: 'subagent.md', SubagentProvider: 'subagent.md', - SubagentResumeRequest: 'subagent.md', + SubagentProviderResumeRequest: 'subagent.md', + SubagentProviderStartRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 10c652f704..0a17861a07 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,6 +1094,11 @@ "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", @@ -1106,7 +1111,17 @@ }, { "doc": "docs/core-data-structures/subagent.md", - "symbol": "SubagentResumeRequest", + "symbol": "SubagentFollowupOptions", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentFollowupResult", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProviderResumeRequest", "source": "packages/subagent/subagent/src/types.ts" }, { From 264bc41a13cc24d66e1d5b2b275854b7f99331d5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 28 Jul 2026 11:19:22 +0800 Subject: [PATCH 23/90] fix(subagent): preserve ordinary start requests --- docs/cordis-catalog/services.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 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/subagent/src/index.ts | 6 ++---- .../subagent/subagent/tests/service.spec.ts | 20 +++++++------------ 7 files changed, 15 insertions(+), 23 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ca1d6f75fc..a1ff16cb7e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2005,7 +2005,7 @@ list(): string[] * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ -async start(name: string, request: SubagentStartRequest): Promise +async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): 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) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6fd7de2f74..a535c7ab81 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: 2dc25dfb14b1506edf7f53f6ce0d8681fefa98c6 -subagent.zh.md: 00f2748ad92ae37b0a2fe2616d9e052f9c4b916f +subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f +subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2dc25dfb14..8f24afec47 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -94,7 +94,7 @@ 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. Raw `SubagentService.start()` clears continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. +Providers receive a separate resolved shape. The `SubagentService.start()` parameter type excludes continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. ```ts type-equiv /** diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 00f2748ad9..50c5cb887e 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -94,7 +94,7 @@ 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()` 才会提供由服务分配的标识和描述符。 +提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 ```ts type-equiv /** diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224bf25e0b..6acad85629 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -905,7 +905,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): Promise', + signature: 'async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): 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 */', }, ], diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 508388e4f5..023ac1fe26 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -298,10 +298,8 @@ 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): Promise { - // A provider request is structurally assignable to the caller shape. Clear - // its wider field so only startContinuable can supply service-owned state. - return this.startProvider(name, { ...request, continuation: undefined }) + 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. */ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 9a274abe22..e86de737ed 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -105,22 +105,16 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('keeps provider continuation state out of raw start and exposes no raw resume operation', async () => { + it('borrows ordinary start requests and exposes no provider continuation operations', async () => { const { subagents } = await service() const provider = new StubProvider('one-shot') subagents.registerProvider(provider) - const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' }) - const sessionId = SessionId('continuable-child') - const parent = fakeParent() - const signal = new AbortController().signal + const request = baseRequest() + await subagents.start('one-shot', request) - const providerRequest: SubagentProviderStartRequest = { - ...baseRequest({ parent, signal }), - continuation: { sessionId, descriptor }, - } - await subagents.start('one-shot', providerRequest) - - expect(provider.lastRequest?.continuation).toBeUndefined() + expect(provider.lastRequest).toBe(request) + expectTypeOf() + .not.toExtend[1]>() expect('resume' in subagents).toBe(false) }) From bb6e6d6f3b832bcb17e213d4cc32c60f565985f6 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 08:37:21 +0800 Subject: [PATCH 24/90] fix(subagent): preserve continuable behavior after rebase --- apps/cli/config/core-web.cordis.yml | 3 + apps/cli/tests/shipped-composition.e2e.ts | 1 + apps/web/tests/shipped-composition.e2e.ts | 1 + docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.zh.md | 20 +-- .../acp-agent/depth-two.cordis.snapshot.yml | 1 + examples/acp-agent/depth-two.cordis.yml | 1 + .../code-mode-workspace-context/session.jsonl | 4 +- .../tests/snapshots/skill-load/session.jsonl | 4 +- .../snapshots/workspace-context/session.jsonl | 4 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- packages/core/agent-loop/src/agent.ts | 2 + packages/core/agent-loop/tests/loop.spec.ts | 135 +++++++++++++++++- packages/core/agent/README.i18n.yaml | 4 +- .../goal-session/tests/goal-session.spec.ts | 31 +--- .../subagent/subagent-fork/README.i18n.yaml | 4 +- packages/subagent/subagent-fork/README.zh.md | 1 - .../subagent/subagent-spawn/README.i18n.yaml | 4 +- packages/subagent/subagent-spawn/README.zh.md | 1 - 22 files changed, 173 insertions(+), 60 deletions(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index d025aef7f4..2a5205cd0d 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -25,6 +25,9 @@ - id: plan-mode disabled: true +- id: tool-subagent-control + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index b6c5adba06..8371f795c8 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -35,6 +35,7 @@ const EXPECTED_TUI_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 3f5ce4e4fa..a9162b631a 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -30,6 +30,7 @@ const EXPECTED_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b1bdfb1a14..a156394d97 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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/core.md -core.md: 70c63d8a84963f56468b5fcdacb580a798de2e10 -core.zh.md: 280529b7de6c3e8546b71791367710c588ad9282 +core.md: 795256a2b30e44771baf8bcb7c1d541134692a02 +core.zh.md: 5cebff049aae53df9f0494da1fadc8dfa5d9ad09 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 280529b7de..5cebff049a 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -568,6 +568,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` 始终会解析。`admitted` 标识其不可变请求历史包含该确切消息的轮次与步骤;`rejected` 表示生命周期或终止策略先丢弃了该消息。同步输入校验仍会从 `steer()` 抛出异常。 + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -669,16 +671,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 4e849d7835..3e292699d1 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -23,6 +23,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 # Re-pin the recorded model: cordis.yml ships deepseek-v4-pro, but this # scenario's corpus was captured on flash. A config patch replaces the diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index 25b0ee8e38..1af96e9283 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -10,4 +10,5 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index d264b20da8..4588ebb898 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"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":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1785122256262,"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":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 6b366448f1..81703a533d 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2243ae1a-2d65-4f9c-a972-d360b8cc08aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"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":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903324926,"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":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464638477,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464638478,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487588943,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 339ba5480d..0a568d3460 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464650864,"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":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903339799,"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":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 70ddefa5ea..ee0434e48c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"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."}},"required":["description","prompt"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"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."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 3d58a9067a..3cb4cabd02 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"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."}},"required":["description","prompt"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"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."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 39d437b56c..d1a5852ea4 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** 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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"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."}},"required":["description","prompt"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"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."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 076ee192f6..28a652be89 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"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."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"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 subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`, and send follow-up messages with `send_message`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output, stop with task_kill, follow up with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"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.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"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."}},"required":["description","prompt"]}},{"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`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"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."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index b41dd69594..90354f0cc2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -71,6 +71,7 @@ function createSteeringDelivery(): SteeringDelivery { return { receipt: { outcome: promise }, settle(outcome): void { + /* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */ if (settled) return settled = true resolve(outcome) @@ -544,6 +545,7 @@ export class ReactLoopAgent implements Agent { this.drainOutbox(turn) break steps } + /* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */ if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue break case 'request-failed': { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 725e3db36a..15bfa7e0fc 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -470,7 +470,10 @@ describe('agent loop', () => { parameters: {}, async execute() { // steer while the turn is running (during tool execution) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })) + agent.send( + createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }), + { target: 'next-step', wakeup: true }, + ) return [{ type: 'text', text: 'tool done' }] }, })) @@ -544,6 +547,120 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) + it('rejects failed steering commits while preserving later context', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'rejected steering' }], + source: { kind: 'user' }, + })) + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'steering/message' && !rejected) { + rejected = true + throw new Error('reject steering commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).not.toContain('rejected steering') + }) + + it('rejects committed steering when the step boundary fails', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'committed steering' }], + source: { kind: 'user' }, + })) + }) + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'step/start') throw new Error('reject step boundary') + }) + + send(agent, 'prompt') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) + expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + }) + + it('retries context and steering after a context commit fails', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'preserved steering' }], + source: { kind: 'user' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string; data?: { source?: { kind: string } } } + if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) { + rejected = true + throw new Error('reject context commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 }) + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).toContain('preserved steering') + }) + it('inject() while idle appends context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -722,12 +839,22 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let receipt: ReturnType | undefined + let contextInjected = false + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'step/end' || contextInjected) return + contextInjected = true + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'final context' }], + source: { kind: 'plugin', plugin: 'finalize' }, + })) + }) ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { - // Steering lands while the concluding tool is still executing. + // Steering lands while the concluding tool is still executing; the + // step/end listener adds ordinary context after the normal result drain. receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] @@ -744,6 +871,9 @@ describe('agent loop', () => { if (receipt === undefined) throw new Error('concluding tool did not submit steering') expect(await receipt.outcome).toEqual({ status: 'rejected' }) expect(events).not.toContain('steering/message') + expect(agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true) send(agent, 'follow up') await waitForIdle(ctx, agent) @@ -752,6 +882,7 @@ describe('agent loop', () => { .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) + expect(texts).toContain('final context') expect(texts).not.toContain('late steering') }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index e94f507b99..78473df4a7 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 8799bc3664b2137b386b752f905e1414fb770cb9 -README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd +README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 +README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 073cb5002f..5a3f0bdca2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' import * as goalSession from '../src/index.ts' declare module '@deepseek-ai/dsh-session' { @@ -787,35 +787,6 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) - it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { - const test = await harness([textResponse('round ran')]) - // A persistent pre-commit turn/end rejection reaches idle with the - // attempt's turn open and no terminal reason. The driver must yield - // instead of clearing the reservation or scheduling another round. - let roundTurn: number | undefined - test.ctx.on('internal/dispatch', (_mode, name, args) => { - if (name !== 'session/event') return - const event = args[1] as SessionEvent - if (event.type === 'turn/start' && event.data.trigger.kind === 'message' - && event.data.trigger.source.kind === 'goal') { - roundTurn = event.data.turn - } - if (event.type === 'turn/end' && event.data.turn === roundTurn) { - throw new Error('turn close permanently rejected') - } - }) - test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' }) - await waitForRequests(test.adapter, 1) - await test.agent.whenIdle() - await new Promise((resolve) => { setImmediate(resolve) }) - - expect(test.adapter.requests).toHaveLength(1) - expect(test.ctx.goals.get(test.agent)).toMatchObject({ - phase: 'active', - activation: 'armed', - }) - }) - it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise | undefined diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml index 14b40beb88..317762e160 100644 --- a/packages/subagent/subagent-fork/README.i18n.yaml +++ b/packages/subagent/subagent-fork/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-fork/README.md -README.md: b448dc309bff07c744443530a648c7c30e4d20d9 -README.zh.md: 3e14206d5637fded9edb4e173608c55e3341f8fc +README.md: 55475aee7841e91960de79887dfe9bf37afdf9da +README.zh.md: 3eec8cb51a47243a1f06416a3f8f99ae8df8e734 diff --git a/packages/subagent/subagent-fork/README.zh.md b/packages/subagent/subagent-fork/README.zh.md index 3e14206d56..3eec8cb51a 100644 --- a/packages/subagent/subagent-fork/README.zh.md +++ b/packages/subagent/subagent-fork/README.zh.md @@ -57,5 +57,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 diff --git a/packages/subagent/subagent-spawn/README.i18n.yaml b/packages/subagent/subagent-spawn/README.i18n.yaml index 970a148213..00eb8d457f 100644 --- a/packages/subagent/subagent-spawn/README.i18n.yaml +++ b/packages/subagent/subagent-spawn/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-spawn/README.md -README.md: 868f829edbcfe2eb4d66ccd0ff9988924c70298b -README.zh.md: 99cfdf0e633345d1152c59cbe5ce7a029eb6ec9d +README.md: 811f19e6e68362bd14e75d0a9059ee61fda3f015 +README.zh.md: 2b189f77c4ff63ca026f472187a55c68def18ea1 diff --git a/packages/subagent/subagent-spawn/README.zh.md b/packages/subagent/subagent-spawn/README.zh.md index 99cfdf0e63..2b189f77c4 100644 --- a/packages/subagent/subagent-spawn/README.zh.md +++ b/packages/subagent/subagent-spawn/README.zh.md @@ -52,5 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 From 26a117f842c9cc87987963a2cd127b2c980e283c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:26:13 +0800 Subject: [PATCH 25/90] 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 26/90] 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 27/90] 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 28/90] 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 29/90] 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 30/90] 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 31/90] 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 32/90] 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 33/90] 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 34/90] 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 35/90] 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 36/90] 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 37/90] 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 38/90] 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 39/90] 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 40/90] 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 41/90] 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 42/90] 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 43/90] 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 44/90] 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 45/90] 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 46/90] 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 47/90] 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 48/90] 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 49/90] 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 50/90] 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 51/90] 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 52/90] 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 53/90] 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 54/90] 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 ` [] —