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" },