diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index cbaedf3ab9..82b4452be3 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238 -2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +2026-07-12-agent-scope-runtime-design.md: a0a9a90bcac6e8f2ed0e06f3fbccb7b1244da278 +2026-07-12-agent-scope-runtime-design.zh.md: 09912162808f2f71d9ea49892cc61bbbeb1cf172 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 232fc02d66..a0a9a90bca 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -270,7 +270,7 @@ Subagent startup has one ownership transfer. The provider owns partial resources `SubagentStartRequest.signal` is required. Aborting it requests cancellation during startup and after readiness. `SubagentRun.dispose()` also requests cancellation and awaits quiescence. There is no separate public `run.cancel()` channel. -Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise` because the resumed child has the same asynchronous readiness boundary. +Optional `SubagentRun.steer()` supports a live backend that can confirm steering admission. Optional `SubagentProvider.resume()` returns `Promise` because a reconstructed child has the same asynchronous readiness boundary. The service validates provider capabilities and request semantics before calling the provider. A provider rejection cleans any partial resources before the rejection escapes and emits no `subagent/start`/`subagent/end` pair. After fulfillment, the service attaches result observation, emits scoped start, and returns the run. Provider removal prevents later starts but does not revoke a run already accepted by the provider. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 39d558f8cd..0991216280 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -270,7 +270,7 @@ Subagent 启动有一次所有权转移。提供方拥有部分资源直到其 s `SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 -可选的 `sendMessage()` 支持能接受 steering 的活跃后端。可选的 `resume()` 返回 `Promise`,因为恢复的子级有相同的异步就绪边界。 +可选的 `SubagentRun.steer()` 支持能够确认 steering 准入的活跃后端。可选的 `SubagentProvider.resume()` 返回 `Promise`,因为重建的子级有相同的异步就绪边界。 服务在调用提供方之前验证提供方能力和请求语义。提供方拒绝在拒绝逃出之前清理所有部分资源,且不发射 `subagent/start`/`subagent/end` 对。兑现之后,服务附加结果观察、发射作用域 start 并返回 run。提供方移除阻止后续 start,但不撤销提供方已接受的 run。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 3d1140f5cf..39fa3f91b7 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 6294c84a8fa11e492316f4b69048aa5f477aa04f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +2026-06-21-subagent-capability-seam.md: d47d8fea1b4c03b19891e6af2d5b1d933feeb553 +2026-06-21-subagent-capability-seam.zh.md: e2fce78d0f76892e03136ab374a116152717f848 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 9c17a93751..d47d8fea1b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -43,7 +43,7 @@ A provider exposes `start(request) → Promise`. Fulfillment publis ### Two kinds of optional capability, discovered two ways - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. -- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. +- **Runtime features** are optional methods at their owning seams: confirmed live delivery is `SubagentRun.steer`, while persisted reconstruction is `SubagentProvider.resume`. Method presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flags object can drift from the implementation. ### Fork vs. fresh are separate backends, not a flag diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 6294c84a8f..e2fce78d0f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -43,7 +43,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 两类可选能力,两种发现方式 - **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 -- **运行时功能**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 +- **运行时功能**是在其所属 seam 上定义的可选方法:提供确认语义的在线投递对应 `SubagentRun.steer`,持久化重建对应 `SubagentProvider.resume`。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flags 对象。 ### Fork 与 fresh 是独立后端,而非一个 flag diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml new file mode 100644 index 0000000000..8813e2d10b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +2026-07-21-continuable-background-subagents.md: 0ea085a3eb9c6e661c1f009f338b264c06f14983 +2026-07-21-continuable-background-subagents.zh.md: 93f4c5b8ba4a052c5a6bb6eac3802601eb0797a5 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..0ea085a3eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -0,0 +1,127 @@ +# Agent Note: Continuable background subagents + +Status: implemented + +English | [中文](2026-07-21-continuable-background-subagents.zh.md) + +The service-placement and provider-capability policy in this record are superseded by [Merge subagent control into the subagent service](../simplification/2026-07-26-merge-subagent-control-service.md) and [Intent-named subagent continuation operations](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md). The continuation, persistence, Task, routing, authorization, and durability semantics remain in force. + +## 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. Each `tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`; configured continuable mode requires the mounted provider's `resume` capability, while a resumable provider may still use one-shot background policy. ACP children remain one-shot until the deferred ACP continuation work below is complete. + +`ctx.subagents` is the only public service. Ordinary `start` stays collection-, Task-, and persistence-agnostic: it validates provider capabilities, dispatches one activation, observes run lifecycle, and returns a holder-owned run. An injected internal continuation manager owns stable child ids, descriptor persistence and lookup, Task-backed activation, and routing through `startContinuable` and `followup`; provider start and resume dispatch use private closures after the manager resolves continuation state. The provider-bound `@deepseek-ai/dsh-tool-subagent` plugin and human-facing adapters call the intent operations for continuable background work; foreground and one-shot background delegation use ordinary `start`. The globally named model tool is a thin optional adapter in `@deepseek-ai/dsh-tool-subagent-control`, and its presence does not decide whether continuable work starts. Parent-to-child enumeration and `list_agents` belong to a separate durable-catalog proposal. + +### Task and cancellation ownership + +The initial background delegation asks `ctx.subagents` to start the child and register its Task. A continuable provider confirms the activation's final session state durable before fulfilling a successful run result. Task settlement awaits that result, calls `run.dispose()` through the continuation manager's settlement path, and only then records the `TaskOutcome`; `task_kill` aborts the active run, whose settlement path still disposes it. A terminal Task therefore leaves the durable child session but no live child Agent. A required durability checkpoint with no installed listener or a failing listener rejects the run with stable code `DURABILITY_FAILED` and the checkpoint failure as its cause; the manager records a failed Task whose detail explains that the latest state was not confirmed persisted and may be unavailable or stale on resume. + +Every later turn creates another Task. Its producer resources cover only that activation, never the child session. It reaches one terminal status, has one result, and is never reopened. The exact live parent Agent remains the Task registry owner: disposing that instance cancels, awaits, and removes its Tasks. Task APIs authorize a caller whose session id matches that owner, but a same-id replacement does not become the notification or teardown target. This preserves the `settleRun()` contract and bounds Task-owned live children by concurrent work rather than historical session count. + +Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the continuation manager. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle. + +`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks-local` and `@deepseek-ai/dsh-tool-tasks` with the subagent tools. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations. + +Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. `followup()` requires the caller's signal, and abort while live steering awaits request admission aborts the activation-owned controller so the provider discards the pending message and the call settles only after the child reaches quiescence. Independent cancellation requires a later message to start a separate turn instead of steering the current one. + +A cold-resume Task creates its activation-owned `AbortController` before descriptor lookup or any provider await; the lookup, direct-parent authorization, and descriptor fold run inside the Task producer, so the same signal covers them and their failures settle that Task as `failed`. A persistence call that has no signal need not stop its underlying I/O, but the continuation manager rechecks cancellation after every such await and cannot begin or publish child work afterward. Before Agent publication, abort makes the provider reject only after its creation transaction has rolled back and reached quiescence. After publication, the provider closes the creation-signal handoff race, attaches the same signal to the live run before returning it, and cancellation stops the child turn. `task_kill` and exact-owner disposal use this path even when provider resume has not returned a `SubagentRun`; Task settlement waits for rollback or run disposal and records `killed` only after the activation is quiescent. + +### Active run association + +The continuation manager keeps a process-local association from child session id to its current Task and, after provider publication, its run. It installs the Task association before awaiting provider start or resume, fills in the returned run, and removes the association only after run disposal and Task terminal publication. This association exists only so parent and human senders can find the same activation; it is not a durable catalog, public `ManagedSubagent`, admission reservation, or run-state machine. + +For a continuable initial activation, the continuation manager allocates the stable child session id before Task creation and passes it as `SubagentProviderStartRequest.continuation`; in-process spawn and fork publish that exact id instead of allocating one internally. Ordinary `SubagentStartRequest` has no continuation field. The background tool returns canonical `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The child id names the durable conversation across activations, while the Task id names only the current activation. A failed initial Task or a process exit before the first child flush can leave an **unmaterialized child**: the caller holds a child id without a durable header and descriptor. Later by-id operations report that id as unavailable (the started Task fails with that detail), and durable enumeration omits it. + +Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the continuation manager synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the manager fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. + +Routing follows the Task association. A running Task accepts live delivery through the run's optional confirmed `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork first synchronously require the child to be `running` and reject an already committed structured capture, then call `Agent.steer()` and await that exact message's admission receipt. The default loop gives every steering item a message-owned receipt and resolves it `admitted` only after `agent/step` and asynchronous prompt assembly succeed, the message is appended, immutable request history is captured, and `step/start` commits; terminal turn policy, cancellation, and disposal resolve pending receipts `rejected`. A non-terminal turn close may carry pending steering into a later queued turn without acknowledging it. Providers must check the live status before `Agent.steer()` so its idle path cannot start a turn outside the observed run. If Task settlement or terminal policy wins after association lookup but before request admission, `steer()` rejects, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. + +The continuation manager does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `followup` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. + +### Model-facing `send_message` + +The model receives one `send_message(subagent_id, message)` tool backed by `SubagentService.followup()`, matching the intent verb on `Agent`. The service operation owns steer-or-resume orchestration and is distinct from the run's `SubagentRun.steer?()`, which only delivers to an already active run. The tool performs no lifecycle routing of its own. It attributes the follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }` and forwards `{ source, signal }`; the service requires both facts in one options object. The source crosses both live steering and cold resume, while cancellation owns only a pending live-delivery wait because a cold-resume Task returns immediately and owns its later cancellation. The child model still receives ordinary user-role content, while the durable source prevents model-generated follow-ups from being classified as direct human input. A human adapter instead supplies `{ kind: 'user' }` and its interaction signal. The tool lives in the separately loaded `@deepseek-ai/dsh-tool-subagent-control` package so provider-bound `@deepseek-ai/dsh-tool-subagent` instances can continue registering distinct delegation tools for spawn, fork, or ACP without registering duplicate global control tools. + +- If the child has a running Task and live-steering capability, the service calls `run.steer(message, source)` and returns the existing Task id; it creates no Task of its own. +- If the child has no running Task, `send_message` creates a fresh Task, cold-resumes the durable session with the message, and returns the new Task id. +- If the active provider cannot accept live delivery, confirmed steering loses its admission race, or a live child exists outside the Task association, `send_message` fails rather than silently starting, resuming, or adopting an untracked turn. + +The service result identifies the route as `steered` with the existing Task id or `started` with the new Task id. Failure is explicit and says that the message was not delivered. The model-facing tool renders these distinctions so timing-dependent routing is observable to the caller. + +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 `followup` operation. The UI may display the child transcript and current Task state, while cancellation calls the Task service with the loaded parent as caller. Tool schema and UI adapters are consumers of one service contract rather than separate execution paths. + +### Durable child handle and cold resume + +The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent. + +The versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) contains the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget. + +Cold resume cannot depend on an optional method of `SubagentRun`, because that run has been disposed and is not retained across process restart. A run represents one disposable activation and exposes only activation-scoped operations. `SubagentRun.steer?()` names the confirmed live-only capability so it cannot be confused with service orchestration or the model-facing tool. + +The internal continuation manager's resume path loads the known child session, folds its descriptor, authorizes the persisted `parentSession`, and runs inside the Task it creates. It passes a fully resolved `SubagentProviderResumeRequest`, including the Task-owned cancellation signal, through a private service closure whose only responsibility is capability-checked provider dispatch and the ordinary run lifecycle observation used by `start`. The selected `SubagentProvider.resume?()` owns transport-specific reconstruction (in-process: `parent.ctx.agents.resume` under the currently loaded parent scope) and returns a fresh run. Presence of the provider method is the continuation capability, so no redundant capability flag exists. `SubagentService.followup()` chooses between the associated run's `steer?()` operation and this cold-resume path. Neither private provider dispatch nor a provider enumerates durable children or associates Tasks. + +The background tool validates and snapshots descriptor inputs before calling `TaskService.start()`. A synchronous validation failure rejects the tool call and creates no Task. The tool otherwise returns the child and Task ids immediately, without waiting for child publication or descriptor durability. In-process continuable providers perform a final `SessionStore.flush()` after the child becomes idle and before reading the result; `true` confirms at least one durability listener participated, `false` is a required-checkpoint failure, and rejection carries a listener failure. This retries a failed loop checkpoint while the child is still live. If the final confirmation fails, the provider rejects instead of returning unconfirmed output, the continuation manager disposes the run, and the already-created Task settles as `failed` with the durability diagnosis in its detail. Cancellation during the confirmation owns the still-unpublished activation result, so a completed child turn or a later checkpoint failure cannot replace the Task's `killed` outcome. Foreground one-shot runs retain the loop's best-effort checkpoint behavior. In-process spawn and fork reconstruct composition under the currently loaded parent scope. A fork resume loads the child's own persisted transcript, which already contains the completed-turn prefix captured at initial creation; it never forks the parent's newer history again. Resuming a parent does not eagerly resume its children. + +TODO (ACP continuation): persist the remote ACP session id as provider-specific descriptor data and implement `AcpProvider.resume?()` as spawn, initialize, `loadSession`, then prompt. The initial ACP run must verify `initialize.agentCapabilities.loadSession`, and every resumed process must use the same durable backend; replayed history from `loadSession` must not be collected as the new activation's output. Because ACP load support is negotiated per child rather than established solely by the provider method's presence, this follow-up must also define how a start result advertises child-specific continuation before ACP children enter the durable catalog. + +### Result and notification ownership + +Every continuable child activation has exactly one Task and one `TaskOutcome`, regardless of whether the parent or a human supplied the first message. The generic Task reporting contract may inject at most one unsolicited completion notice to the retained parent owner while the Task is unreported; reads, waits, and cancellation may suppress it. Running delivery joins that activation and creates neither a second Task nor a second result. The child transcript remains the human-facing detailed record; Task output remains the parent-facing final result. + +Task records and active-run associations are process-local. Persistence makes the child session resumable after restart, but does not recover an interrupted Task, its result, or its notification. Durable Task recovery is a separate concern. + +## Alternatives considered + +**Retain every background child after Task settlement.** This is the Codex-style resident-session model: follow-up delivery is cheap, but historical children retain Agent scopes, session memory, listeners, and provider resources until an explicit residency limit or eviction policy removes them. Per-activation disposal uses persistence as the continuation boundary and preserves the current resource bound. + +**Let human turns run without Tasks.** A parent message joining such a turn has no Task result or completion notice, and UI cancellation has unclear effects on the parent's contribution. Giving every activation one Task makes completion and cancellation properties of the child turn rather than its initiating caller. + +**Keep one Task for the lifetime of a child session.** A terminal Task cannot naturally become running again, and one result cannot represent multiple turns. Fresh activation-scoped Tasks preserve the generic Task contract. + +**Create a Task for every message.** Steering joins an existing turn and has no independent final result, so a Task created for steering would duplicate the active Task or report a result it does not own. Only a message that starts an activation creates a Task. + +**Split `send_message` and `follow_up`.** Separate delivery operations expose an implementation-state distinction to the model without removing stopped-child races. One operation follows the Claude Code model: deliver to running work or resume a new Task-backed lifecycle. + +**Keep `resume?()` on the disposed run.** Retaining a disposed `SubagentRun` only to call `resume()` makes the old run double as a durable child handle and cannot reconstruct that object after restart. Service dispatch plus provider reconstruction makes the persistence boundary explicit. + +**Put control orchestration on `SubagentService`.** This service-placement alternative is the [merged-service decision](../simplification/2026-07-26-merge-subagent-control-service.md); the [intent-operation refinement](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md) keeps provider start/resume dispatch reusable only inside the service while isolating optional Task and persistence work in an injected internal manager. + +**Add explicit activation phases.** Public `starting`/`running`/`settling` states could describe admission and cleanup precisely, but would add a lifecycle protocol the implementation does not otherwise need. The synchronous association install closes duplicate process-local cold resume without exposing those phases. + +## Testing + +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: an absent or detached flush listener and a permanent listener failure reject with `DURABILITY_FAILED`, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent/tests/continuation.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service, and `ctx.subagents`) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is pre-turn, model-hidden, versioned, durable under the service-allocated child id, and survives blocked or throwing initial prompt admission; cancellation, steering, cold follow-up, authorization, ownership conflicts, and resume races retain the contracts described above. +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal. +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the configured background route: continuable mode requires a resumable provider and returns both ids without requiring `send_message`, while one-shot mode keeps the plain Task acknowledgement even when the provider can resume. +- `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions. +- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable. + +## Consequences + +- 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 continuation manager. The Agent registry prevents duplicate same-session publication; a losing Task fails and its message is not delivered. A message may also race cancellation, terminal status publication, or run disposal. Admission is not claimed to be atomic or exactly-once; the synchronous process-local association install closes duplicate cold resume through `followup` without a public lifecycle state machine. +- Driving a continuable child through the ordinary Agent API bypasses its Task association. `ctx.subagents` rejects that live child as an ownership conflict; adapters must display persisted transcripts without loading an Agent and submit human input through `SubagentService.followup()`. +- The active-run association coordinates only one runtime. Concurrent resume from multiple processes is not serialized; that deployment requires a persistence-level lease or compare-and-set operation. +- Human interaction requires the exact parent Agent instance to remain live because owner disposal cancels and removes its Tasks. It also requires an attached Task control surface. Standalone child interaction requires a future separation between Task access ownership and durable notification targeting. +- The background tool returns child and Task ids before child publication and descriptor durability. Startup failure, a failed final durability confirmation, or process exit before the first child flush leaves the Task failed and may leave an unmaterialized or stale child id; by-id control reports missing state as unavailable rather than retroactively changing the tool acknowledgement. +- 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..93f4c5b8ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -0,0 +1,127 @@ +# Agent Note: 可继续的后台 subagent + +Status: implemented + +[English](2026-07-21-continuable-background-subagents.md) | 中文 + +本记录中的服务放置与提供方功能策略已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。继续执行、持久化、Task、路由、授权和持久性语义仍然有效。 + +## 问题 + +subagent 工具将每次委派视为一个独占的 `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。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 功能,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。 + +`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Task 与持久化:它校验提供方功能、分发一次激活、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、描述符持久化与查找、由 Task 支撑的激活,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器解析继续执行状态后,提供方的 start 与 resume 分发通过私有闭包进行。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举与 `list_agents` 属于单独的持久化目录提案。 + +### Task 与取消的所有权 + +初始后台委派请求 `ctx.subagents` 启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,通过继续执行管理器的结算路径调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;管理器会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。 + +后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。 + +用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过继续执行管理器,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。 + +如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。 + +取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。 + +从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但继续执行管理器必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。 + +### 活跃 run 关联 + +继续执行管理器在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。 + +对于可继续 child 的初始激活,继续执行管理器会在创建 Task 前分配稳定的 child session id,并将其作为 `SubagentProviderStartRequest.continuation` 传递;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。普通 `SubagentStartRequest` 不含 continuation 字段。后台工具返回规范的 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。 + +每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,继续执行管理器会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:管理器会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 + +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/step` 与异步提示词组装成功后,系统追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 + +继续执行管理器不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `followup` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 + +### 面向模型的 `send_message` + +模型获得一个由 `SubagentService.followup()` 支撑的 `send_message(subagent_id, message)` 工具,与 `Agent` 上的意图动词一致。该服务操作负责在 steering 与恢复之间编排;它不同于 run 的 `SubagentRun.steer?()`,后者只能向已活跃的 run 发送消息。工具本身不执行生命周期路由。该工具将后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`,并转发 `{ source, signal }`;服务要求在一个选项对象中同时提供这两项信息。来源会贯穿在线 steering 和 cold resume 两条路径,而取消只控制尚未完成的在线投递等待,因为 cold resume Task 会立即返回,并自行负责后续取消。child 模型收到的仍是普通的 user role 内容,而持久化的来源信息可防止模型生成的后续消息被归类为直接用户输入。用户适配器则提供 `{ kind: 'user' }` 及其交互信号。该工具位于单独加载的 `@deepseek-ai/dsh-tool-subagent-control` 包中,因此按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 实例可以继续为 spawn、fork 或 ACP 注册不同的委派工具,而不会重复注册全局控制工具。 + +- 如果 child 存在运行中的 Task 并支持在线消息,服务会调用 `run.steer(message, source)` 并返回现有 task id;它不会创建新 Task。 +- 如果 child 没有运行中的 Task,`send_message` 会创建新 Task,使用该消息从持久化存储恢复会话,并返回新的 task id。 +- 如果活跃提供方无法接收在线消息、带确认语义的 steering 在准入竞态中失败,或 Task 关联之外存在存活 child,`send_message` 会失败,而不会静默启动、恢复或接管未受跟踪的轮次。 + +服务结果将路由标识为 `steered` 并携带现有 task id,或标识为 `started` 并携带新的 task id。失败结果会明确说明消息未送达。面向模型的工具会呈现这些差异,让调用方能够观察由时序决定的实际路由。 + +发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。 + +用户输入使用同一个 `followup` 操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个服务契约,不建立彼此独立的执行路径。 + +### 持久化 child handle 与从持久化存储恢复 + +继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。 + +版本化描述符([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,并且进程重启后不会保留。run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。`SubagentRun.steer?()` 这一名称明确指代提供确认语义且仅适用于在线消息的功能,以免该功能与服务编排或面向模型的工具混淆。 + +内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。 + +后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少有一个持久性监听器参与,返回 `false` 表示必需的检查点失败,而拒绝则携带监听器失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。 + +TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。 + +### 结果与通知所有权 + +每次可继续 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` 上。** 这一服务放置方案即[服务合并决策](../simplification/2026-07-26-merge-subagent-control-service.md);[意图操作细化](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)将提供方 start/resume 分发的复用限制在服务内部,同时将可选的 Task 与持久化工作隔离在注入的内部管理器中。 + +**增加显式激活阶段。** 公开的 `starting`/`running`/`settling` 状态可以准确描述准入和清理,但会引入实现本身并不需要的生命周期协议。同步安装关联无需暴露这些阶段,即可消除进程内重复的 cold resume。 + +## 测试 + +- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。 +- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。 +- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。 +- `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。 +- 无密钥 ACP 快照场景 `subagent-continuable`(examples/acp-agent)固定模型可见的 transcript:双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。 + +## 影响 + +- 每次完成结算后的后续轮次都需要承担持久化加载和作用域 setup 成本;作为交换,存活 child 的数量受并发工作量限制,而不是随历史会话数量增长。持久化不可用或存储的组合配置无法重建时,可继续 child 的创建会明确失败。 +- 两个调用方仍可能通过继续执行管理器外部的路径争抢已停止的 child。Agent 注册表会阻止相同会话的重复发布;失败的 Task 会失败,且其消息不会送达。消息也可能与取消、终态状态发布或 run dispose 发生竞态。准入不承诺原子或恰好执行一次;在进程内同步安装的关联无需公开生命周期状态机,即可通过 `followup` 消除重复的 cold resume。 +- 通过普通 Agent API 驱动可继续 child 会绕过其 Task 关联。`ctx.subagents` 会将该存活 child 视为所有权冲突并拒绝;适配器必须在不加载 Agent 的情况下展示持久化 transcript,并通过 `SubagentService.followup()` 提交用户输入。 +- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。 +- 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。 +- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。 +- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。 +- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml new file mode 100644 index 0000000000..f28c1f6a8e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md +2026-07-26-merge-subagent-control-service.md: 84995446939d0f47e008bffb38083b1b6e0706de +2026-07-26-merge-subagent-control-service.zh.md: 7f82555159bfea9e00fa4cc2afdcf30382f3f776 diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md new file mode 100644 index 0000000000..8499544693 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md @@ -0,0 +1,41 @@ +# Agent Note: Merge subagent control into the subagent service + +Status: implemented + +English | [中文](2026-07-26-merge-subagent-control-service.zh.md) + +The public operation set is refined by [Intent-named subagent continuation operations](2026-07-27-intent-named-subagent-continuation-operations.md). + +## Problem + +Continuable-child orchestration originally lived in a separate `ctx.subagentControl` service above the raw `ctx.subagents` provider seam. That split kept provider dispatch independent of Tasks and persistence, and gave model and human adapters one orchestration contract. In practice the two services described one capability family, every continuable caller needed both, and the provider-bound delegation tool had to infer policy from `provider.resume` and inspect whether the control service and `send_message` tool happened to be loaded. This made sibling plugin presence decide execution semantics and coupled starting continuable work to an optional follow-up surface. + +## Decision + +`SubagentService` is the only public service. It exposes ordinary `start(name, request)`, Task-backed `startContinuable(spec)`, and intent-named `followup(...)`; provider resume dispatch remains private to its continuation manager. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are absent; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly. + +The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class. + +The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations. + +`startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry. + +Each `@deepseek-ai/dsh-tool-subagent` instance selects `backgroundMode: 'one-shot' | 'continuable'`, defaulting to `one-shot`. This configuration is policy; `provider.resume` is only the capability check for configured continuable mode. A resumable provider can therefore still run one-shot background work. The `send_message` tool is an independent adapter: loading or omitting it neither enables nor disables `startContinuable`. + +## Alternatives considered + +**Keep the separate service.** This preserves the strongest dependency separation, but every production continuable path composes both services and the extra public key exposes an architectural distinction callers do not need. The internal manager preserves optional Task and persistence dependencies without a second service. + +**Infer continuable mode from `provider.resume`.** Method presence correctly states cold-resume capability but not deployment policy. It forced every resumable provider into continuable background semantics and made missing sibling plugins a runtime error. Explicit tool configuration separates choice from capability. + +**Register continuation access or inspect the follow-up tool.** A registry could tell the delegation tool whether a continuation surface exists, but starting durable work does not require any follow-up adapter. Such a registry would encode UI composition into execution policy and recreate the sibling dependency under another name. + +**Merge raw and continuable starts into one method.** A flag on `start` would return either a ready run or immediate Task and child identities, weakening a simple ownership boundary. Keeping `startContinuable` is the smaller change and preserves both contracts explicitly. + +## Consequences + +- The service topology has one public key and one package fewer while raw provider dispatch remains usable without Tasks or persistence. +- Continuable mode fails at provider mount when the configured provider lacks `resume`; missing Tasks, Agents, or persistence still fail at the earliest operation that requires them. +- Follow-up delivery remains optional. Deployments may start and collect continuable work through Task tools without exposing `send_message`. +- The continuation manager is still Task- and persistence-aware inside the `dsh-subagent` package, so the package declares optional peer dependencies on those services even though ordinary `start` callers do not need them. +- Existing continuation races, authorization, durability, cancellation, and settle-then-dispose semantics are unchanged and remain pinned by the migrated `subagent` tests. diff --git a/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md new file mode 100644 index 0000000000..7f82555159 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 将 subagent 控制合并到 subagent 服务 + +Status: implemented + +[English](2026-07-26-merge-subagent-control-service.md) | 中文 + +公开操作集合由[以意图命名的 subagent 继续执行操作](2026-07-27-intent-named-subagent-continuation-operations.md)进一步细化。 + +## 问题 + +可继续 child 的编排最初位于原始 `ctx.subagents` 提供方 seam 之上的独立 `ctx.subagentControl` 服务中。该拆分使提供方分发与 Task 和持久化无关,并为模型与人工适配器提供统一的编排契约。实践中,两个服务属于同一组功能,每个可继续调用方都需要二者,而绑定提供方的委派工具必须根据 `provider.resume` 推断策略,并检查控制服务与 `send_message` 工具是否碰巧已加载。如此一来,配套插件是否存在会决定执行语义,并将可继续工作的启动耦合到可选的后续操作接口。 + +## 决策 + +`SubagentService` 是唯一的公开服务。它公开普通的 `start(name, request)`、由 Task 支撑的 `startContinuable(spec)`,以及按意图命名的 `followup(...)`;提供方的 resume 分发仍封装在其继续执行管理器内部。独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键均不存在;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`。 + +合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。 + +继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。 + +`startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。 + +每个 `@deepseek-ai/dsh-tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`,默认值为 `one-shot`。这项配置表示策略;`provider.resume` 只用于检查所配置的可继续模式是否受提供方支持。因此,可恢复的提供方仍可执行一次性后台工作。`send_message` 工具是独立适配器:加载或省略该工具既不会启用也不会禁用 `startContinuable`。 + +## 已考虑的替代方案 + +**保留独立服务。** 这样能保持最严格的依赖分离,但每条生产环境中的可继续路径都要组合两个服务,而额外的公开键会暴露调用方并不需要的架构差异。内部管理器无需第二个服务,也能保留可选的 Task 和持久化依赖。 + +**根据 `provider.resume` 推断可继续模式。** 方法是否存在可以准确表示从持久化存储恢复的功能,却不能表示部署策略。这会迫使每个可恢复的提供方都采用可继续后台语义,并使配套插件缺失成为运行时错误。显式的工具配置将选择与功能分离。 + +**注册继续执行访问入口,或检查后续操作工具。** 注册表可以告诉委派工具继续执行接口是否存在,但启动具备持久性的工作不需要任何后续操作适配器。这样的注册表会把 UI 组合编码进执行策略,并以另一个名称重新建立插件间依赖关系。 + +**将底层启动与可继续启动合并为一个方法。** `start` 上的标志会使该方法返回就绪的 run,或立即返回 Task 和 child 标识,从而削弱简单的所有权边界。保留 `startContinuable` 改动更小,也能明确保留两项契约。 + +## 影响 + +- 服务拓扑少了一个公开键和一个包,同时底层提供方分发仍可在没有 Task 或持久化时使用。 +- 配置的提供方缺少 `resume` 时,可继续模式会在提供方挂载阶段失败;缺少 Task、Agent 或持久化时,仍会在需要它们的最早操作处失败。 +- 后续消息投递仍为可选功能。部署可以通过 Task 工具启动并收集可继续工作,而不公开 `send_message`。 +- `dsh-subagent` 包内的继续执行管理器仍然感知 Task 和持久化,因此该包会将这些服务声明为可选的对等依赖(peer dependency),即使普通的 `start` 调用方并不需要它们。 +- 现有的继续执行竞态、授权、持久性、取消及先结算再 dispose 的语义均保持不变,并继续由迁移后的 `subagent` 测试固定。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml new file mode 100644 index 0000000000..5623e559bc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +2026-07-27-intent-named-subagent-continuation-operations.md: 1155e6b2fb89661021ebdbd6310902e74a500078 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 5f434cd8fbb171ef77a3b1f307029d6ade09f1d6 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md new file mode 100644 index 0000000000..1155e6b2fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -0,0 +1,36 @@ +# Agent Note: Intent-named subagent continuation operations + +Status: implemented + +English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) + +## Problem + +Merging continuable-child orchestration into `ctx.subagents` left provider dispatch and caller intent on the same public service. `resume(name, request)` accepted a descriptor, authorized parent, durable child id, and activation signal that only the internal continuation manager could resolve correctly. `sendMessage(...)` exposed transport wording rather than the `followup` intent already used by `Agent`, and its separate source and signal parameters widened an operation every caller had to use atomically. + +The durability boundary also exposed both `SessionStore.flush()` and `flushRequired()`. They performed the same scoped parallel dispatch and differed only in whether an empty listener snapshot was accepted, so the session interface encoded one consumer's policy as a second operation. + +## Decision + +`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. + +Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. + +`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. + +## Alternatives considered + +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. + +**Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. + +**Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. + +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. + +## Consequences + +- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. +- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. +- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md new file mode 100644 index 0000000000..5f434cd8fb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 按意图命名的 subagent 继续执行操作 + +Status: implemented + +[English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 + +## 问题 + +将可继续 child 的编排合并到 `ctx.subagents` 后,提供方分发与调用方意图共存于同一个公开服务中。`resume(name, request)` 接受描述符、已鉴权的 parent、持久化 child id 与激活信号,而只有内部继续执行管理器才能正确解析这些数据。`sendMessage(...)` 暴露的是传输层措辞,而不是 `Agent` 已采用的 `followup` 意图;它还将来源与信号拆成独立参数,扩大了操作接口,而每个调用方都必须以原子方式同时使用二者。 + +持久性边界还同时公开了 `SessionStore.flush()` 与 `flushRequired()`。二者执行相同的作用域内并行分发,唯一差别是是否接受空的监听器快照,因此会话接口将一个消费方的策略编码为第二项操作。 + +## 决策 + +`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 + +调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 + +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 + +## 已考虑的替代方案 + +**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 + +**在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 + +**保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 + +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 + +## 影响 + +- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 +- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 +- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index c4deb098c4..189a8af496 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -94,6 +94,8 @@ flowchart LR cfg --> plugin_tui_subagent_spawn plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_tui_subagent_fork + plugin_tui_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_tui_tool_subagent_control plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_tui_tool_subagent plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -185,6 +187,7 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index df7ed94258..891e2b359f 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -257,17 +257,24 @@ config: providerName: fork +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn toolName: subagent + backgroundMode: continuable - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + backgroundMode: continuable - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index d025aef7f4..2a5205cd0d 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -25,6 +25,9 @@ - id: plan-mode disabled: true +- id: tool-subagent-control + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index 58368f04d9..e9c719dce7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -120,6 +120,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/apps/cli/tests/shipped-composition.e2e.ts b/apps/cli/tests/shipped-composition.e2e.ts index b6c5adba06..8371f795c8 100644 --- a/apps/cli/tests/shipped-composition.e2e.ts +++ b/apps/cli/tests/shipped-composition.e2e.ts @@ -35,6 +35,7 @@ const EXPECTED_TUI_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 3f5ce4e4fa..a9162b631a 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -30,6 +30,7 @@ const EXPECTED_TOOLS = [ 'get_goal', 'ralph', 'read', + 'send_message', 'skill', 'str_replace_editor', 'subagent', diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e305eb5f42..2d14dedd68 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 75340b6fb3e4e109974bcb9d9ccabec004d853e1 -architecture.zh.md: 0955a40f3571fe9146c75a6b1094f945fcc5219c +architecture.md: 44be3b55ab5061490a2ceb632175c1bb53a21330 +architecture.zh.md: d803bea1ba39e8fd07a01446dd2d2ae53aca35e1 diff --git a/docs/architecture.md b/docs/architecture.md index 75340b6fb3..44be3b55ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers plus optional Task-backed continuation and steer-or-resume routing | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | @@ -92,11 +92,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -105,10 +106,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -120,7 +121,7 @@ idle inject: Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). @@ -134,7 +135,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Handles -`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. `cancel()` and `whenIdle()` control lifecycle. Awaited disposal owns teardown. +`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0955a40f35..d803bea1ba 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -38,7 +38,7 @@ | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | | `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | -| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方,以及可选的由 Task 支撑的继续执行与 steer-or-resume 路由 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | @@ -92,11 +92,12 @@ forever: append prompt + additional contexts as separate 'user/message' events STEP loop: agent/step - drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tools materialize changed runtime context as sourced 'user/message' + drain injected context and provisional steering (steering bypasses prompt-submit) snapshot the derived messages (the reconstruction boundary) 'step/start' + admit the drained steering receipts agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound) 'assistant/chunk' 'assistant/message' @@ -105,10 +106,10 @@ forever: parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute model-order result -> ordered tools/post-execute -> 'tool/result' - drain accepted tool context and steering + drain accepted tool context after all results; keep steering provisional 'step/end' - continue for tools or steering unless a result concluded the turn - otherwise agent/turn-stopping -> drain -> continue only for steering + continue for tools or steering unless a result concluded the turn and rejects pending steering + otherwise agent/turn-stopping -> drain context -> continue only for steering close the next-step acceptance window 'turn/end' -> agent/settled start the next waking queued message, or emit agent/status(idle) @@ -120,7 +121,7 @@ idle inject: 每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 @@ -134,7 +135,7 @@ idle inject: ### Agent 句柄 -`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用 `followup()`、`steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。`cancel()` 与 `whenIdle()` 控制生命周期。需等待完成的资源释放负责拆卸。 +`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()` 或 `followup()`、带回执的 `steer()` 和 `inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()` 与 `whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程。 ### Agent 作用域 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d31ee37d8e..2681bd728a 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -132,9 +132,10 @@ flowchart LR pkg_compact["compact"] svc_compact["ctx.compact
Compaction seam"] pkg_subagent["subagent"] - svc_subagents["ctx.subagents
Subagent provider registry"] + svc_subagents["ctx.subagents
Subagent provider and continuation service"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] + pkg_tool_subagent_control["tool-subagent-control"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] @@ -313,6 +314,7 @@ flowchart LR svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subagents --> pkg_tool_subagent_control svc_subprocess --> pkg_bash_local svc_subprocess --> pkg_bash_sandbox svc_subprocess --> pkg_lsp_local @@ -389,7 +391,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad12c61134..33bd5777e6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1582,7 +1582,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:30`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1596,7 +1596,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagent/subagent-spawn/src/index.ts) +Source: [`packages/subagent/subagent-spawn/src/index.ts:25`](../packages/subagent/subagent-spawn/src/index.ts) ## `@deepseek-ai/dsh-system-prompt` @@ -1893,6 +1893,12 @@ export interface Config { * parameter and reject forced background calls. */ enableRunInBackground?: boolean + /** + * Background execution policy (default `one-shot`). `continuable` requires + * a provider with persisted resume support and returns both child and Task + * ids; follow-up adapters remain independently optional. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -1928,7 +1934,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:25`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` @@ -2347,6 +2353,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1f9386fe39..c7b0734585 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:402`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:421`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -685,13 +685,12 @@ Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/sr ### `session/flush` — parallel -Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -702,7 +701,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) ## `settings/*` @@ -795,7 +794,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:166`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -812,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -827,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:120`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:146`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -849,7 +848,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:157`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22bc6343ea..a1ff16cb7e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1635,10 +1635,11 @@ announce(session: Session): void * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ -async flush(session: Session): Promise +async flush(session: Session): Promise /** * Look up a live session. @@ -1672,7 +1673,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:765`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:764`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1948,9 +1949,31 @@ Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/stora ## `ctx.subagents` — `SubagentService` -Named provider registry and capability-checked start surface. +Named provider registry with raw and Task-backed continuation operations. ```ts cordis-catalog +/** + * Start one durable continuable child through a Task-backed initial + * activation. + * @param spec - provider, Task label, and delegation request. + * @returns the stable child id and initial activation Task id. + */ +startContinuable(spec: ContinuableStartSpec): ContinuableStart + +/** + * Follow up with a continuable child. A live child is steered and fulfillment + * confirms request admission; an idle child immediately returns a fresh Task + * whose descriptor lookup, authorization, and cold resume may later fail. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param content - user-role content to deliver. + * @param options - durable attribution and caller cancellation; aborting a + * live-delivery wait cancels the shared activation and awaits quiescence. + * @returns the existing steered Task or newly started Task. + * @throws when continuation services are unavailable or live delivery is not admitted. + */ +followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that @@ -1982,12 +2005,12 @@ list(): string[] * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ -async start(name: string, request: SubagentStartRequest): Promise +async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise ``` -Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentFollowupResult](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:181`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:199`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b1bdfb1a14..a156394d97 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 70c63d8a84963f56468b5fcdacb580a798de2e10 -core.zh.md: 280529b7de6c3e8546b71791367710c588ad9282 +core.md: 795256a2b30e44771baf8bcb7c1d541134692a02 +core.zh.md: 5cebff049aae53df9f0494da1fadc8dfa5d9ad09 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 70c63d8a84..795256a2b3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -560,6 +560,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` always resolves. `admitted` identifies the turn and step whose immutable request history contains that exact message; `rejected` means lifecycle or terminal policy discarded it first. Synchronous input validation still throws from `steer()`. + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -661,16 +663,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 280529b7de..5cebff049a 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -568,6 +568,8 @@ interface CancelOptions { } ``` +`SteeringReceipt.outcome` 始终会解析。`admitted` 标识其不可变请求历史包含该确切消息的轮次与步骤;`rejected` 表示生命周期或终止策略先丢弃了该消息。同步输入校验仍会从 `steer()` 抛出异常。 + ```ts type-equiv /** Stable runtime cause accepted by {@link Agent.cancel}. */ type AgentCancelCause = @@ -669,16 +671,18 @@ interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index af4b1decda..a535c7ab81 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff -subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2 +# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md +subagent.md: 8f24afec47a970711aae49cae6b3535b9f532e5f +subagent.zh.md: 50c5cb887ef814c074a85fc4fee9cd2fe85d685c diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2497dbab9c..8f24afec47 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 optional global `send_message`). The same `ctx.subagents` service owns continuable-child orchestration through an internal Task-backed manager. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable background subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). -Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## Two kinds of capability, discovered two ways -A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (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: confirmed live steering is [`SubagentRun.steer`](#a-live-run-subagentrun) and persisted cold resume is [`SubagentProvider.resume`](#the-provider-seam-subagentprovider). ```ts type-equiv /** @@ -18,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 — confirmed live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -38,8 +39,8 @@ The tool layer builds this request from the model input and its own config; the /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -93,6 +94,114 @@ interface SubagentStartRequest { `signal` is the single cancellation channel before and after readiness. The [subagent composition-controls Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. +Providers receive a separate resolved shape. The `SubagentService.start()` parameter type excludes continuation state, while `startContinuable()` alone supplies the service-allocated identity and descriptor. + +```ts type-equiv +/** + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} +``` + +## Continuable children and provider resume + +A **continuable background subagent** is a durable child session with a series of Task-backed activations. `SubagentService.startContinuable()` allocates the stable child id, snapshots the versioned `subagent/descriptor` payload, and passes both through the provider-facing start request; the provider publishes exactly that id and appends the descriptor before the initial prompt is admitted. `SubagentService.followup()` mirrors the intent verb on `Agent`: it steers a live activation or privately dispatches a resolved provider resume after loading and authorizing a stopped child. An internal manager owns descriptor lookup and Task association only while `ctx.tasks` and `ctx.agents` exist; persistence is required per continuation operation, not to load the provider registry. `startContinuable()` returns both identities, while `followup()` reports whether the content `steered` the existing Task or `started` a fresh one. Every sender supplies a `MessageSource` and cancellation signal through one options object; abort while live delivery awaits admission cancels the shared activation and rejects after quiescence. The optional model-facing tool uses `CoordinatorMessageSource` and its tool-execution signal, while a human adapter uses `{ kind: 'user' }` and its interaction signal. + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` + +```ts type-equiv +/** + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * How a continuable follow-up was routed: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. + */ +type SubagentFollowupResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. + */ +interface SubagentContinuation { + /** Service-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} +``` + +```ts type-equiv +/** + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +interface SubagentProviderResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + 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 +251,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. A completed continuable result additionally means the provider confirmed the activation's final state durable; a failed required checkpoint rejects. The optional confirmed `steer` method advertises live delivery by presence and fulfills only after a request snapshot admits the message. Cold resume is a provider-level operation: `SubagentProvider.resume` reconstructs a fresh run from the child's persisted session because the process-local run ceases to exist after disposal or process restart. ```ts type-equiv /** @@ -167,8 +276,10 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** @@ -177,15 +288,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * OPTIONAL (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` @@ -220,11 +332,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects continuable starts and cold-resume dispatch on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentProviderResumeRequest): 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. +Provider `start()` fulfills only with a ready run; provider `resume()` shares the same publication and lifecycle-observation contract but is dispatched only by the continuation manager. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 2d96e9bc63..50c5cb887e 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,13 +4,13 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`)。同一个 `ctx.subagents` 服务通过由 Task 支撑的内部管理器负责可继续子 agent 编排。设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续后台 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 -源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) +源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、恢复)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 +提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性则是可选方法;方法存在即为能力,TypeScript 的类型收窄即为发现机制:提供确认语义的在线 steering(中途引导)是 [`SubagentRun.steer`](#a-live-run-subagentrun),从持久化存储恢复是 [`SubagentProvider.resume`](#the-provider-seam-subagentprovider)。 ```ts type-equiv /** @@ -18,9 +18,10 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * degradation" rule). These static flags cover features needed before a run exists; runtime - * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence - * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option: - * `depthLimit` to `maxDepth`; the other names match. + * capabilities are optional methods whose presence is the capability — confirmed live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ interface SubagentCapabilities { readonly outputSchema: boolean @@ -38,8 +39,8 @@ interface SubagentCapabilities { /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -93,6 +94,114 @@ interface SubagentStartRequest { `signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 +提供方会接收单独的已解析请求类型。`SubagentService.start()` 的参数类型不包含继续执行状态;只有 `startContinuable()` 才会提供由服务分配的标识和描述符。 + +```ts type-equiv +/** + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} +``` + +## 可继续子 agent 与提供方恢复 + +**可继续后台 subagent** 是一份持久化子 agent 会话,由一系列由 Task 支撑的激活组成。`SubagentService.startContinuable()` 会分配稳定的子 agent id、对版本化的 `subagent/descriptor` payload 建立快照,并通过面向提供方的启动请求传入二者;提供方会准确发布该 id,并在初始 prompt 获准前追加描述符。`SubagentService.followup()` 沿用 `Agent` 的意图动词:它会引导实时激活,或在加载并授权已停止的子 agent 后,仅在内部向提供方分发已解析的恢复请求。只有 `ctx.tasks` 和 `ctx.agents` 存在时,内部管理器才会负责描述符查找与 Task 关联;每项继续执行操作都要求持久化,而加载提供方注册表不要求持久化。`startContinuable()` 返回两个标识,`followup()` 则报告内容是对现有 Task 执行了 `steered`,还是 `started` 一个新 Task。每个发送方都通过一个选项对象提供 `MessageSource` 和取消信号;若在在线投递等待准入期间中止该信号,则会取消共享激活,并在其完全停稳后拒绝调用。可选的面向模型工具使用 `CoordinatorMessageSource` 及其工具执行信号,人工适配器则使用 `{ kind: 'user' }` 及其交互信号。 + +```ts type-equiv +/** Attribution for a model coordinator's follow-up to one of its children. */ +interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} +``` + +```ts type-equiv +/** + * Options for following up with one continuable child. + */ +interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** + * How a continuable follow-up was routed: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. + */ +type SubagentFollowupResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } +``` + +```ts type-equiv +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. + */ +interface SubagentContinuation { + /** Service-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} +``` + +```ts type-equiv +/** + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +interface SubagentProviderResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + readonly parent: Agent + /** + * Activation-owned cancellation signal, created before descriptor lookup. + * Same pre/post-publication contract as {@link SubagentStartRequest.signal}: + * an abort before publication rejects after rollback quiescence, and an + * abort afterward cancels the published child turn. + */ + readonly signal: AbortSignal + /** The folded durable descriptor whose composition the provider reconstructs. */ + readonly descriptor: SubagentDescriptorData +} +``` + +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)会对显式字段建立快照,包括提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;它绝不会对可通过合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则必须明确更改版本。描述符省略 `subagentDepth`(从持久化存储恢复时,以持久化 header 中的 `delegationDepth` 为单调下界)和 `outputSchema`(单次激活的结果契约,而非持久化组合配置)。`subagent/descriptor` 事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。 + ## 终态结果:`SubagentResult` 一次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 @@ -144,7 +253,7 @@ interface SubagentStopReasonMap { ## 活跃 run:`SubagentRun` -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄;它表示一次可 dispose(资源释放)的激活,绝不是持久化子 agent handle。消费方 await `result` 并始终 dispose 该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可继续结果为 completed 还表示提供方已确认本次激活的最终状态具备持久性;必需检查点失败则会 reject。可选且提供确认语义的 `steer` 方法通过自身的存在公布在线投递功能,并且只有在请求快照准入该消息后才会兑现。从持久化存储恢复属于提供方级操作:`SubagentProvider.resume` 会根据子 agent 的持久化会话重建一个新 run,因为进程内 run 在 dispose 或进程重启后就不再存在。 ```ts type-equiv /** @@ -169,8 +278,10 @@ interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** @@ -179,15 +290,16 @@ interface SubagentRun { */ dispose(): Promise /** - * OPTIONAL (steering capability): send additional content to the running - * child between steps. Present only on providers that support live steering. + * OPTIONAL (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[], source: MessageSource): Promise } ``` @@ -222,11 +334,22 @@ interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects continuable starts and cold-resume dispatch on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentProviderResumeRequest): Promise } ``` -`start()` 仅在 run 就绪时 fulfill。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 +提供方的 `start()` 仅在 run 就绪时 fulfill;提供方的 `resume()` 采用相同的发布与生命周期观察契约,但只有继续执行管理器会分发它。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f2007c814a..33f004e102 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:386`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:421`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | @@ -37,14 +37,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts: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:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5ba907f02f..3e6889c80e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -70,6 +70,7 @@ flowchart TD 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"] @@ -738,6 +739,8 @@ flowchart TD pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm @@ -905,6 +908,11 @@ flowchart TD pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_tasks pkg_tool_subagent --> pkg_tools + pkg_tool_subagent_control --> pkg_invariants + pkg_tool_subagent_control --> pkg_llm + pkg_tool_subagent_control --> pkg_session + pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_tools pkg_repository_plugin --> pkg_invariants pkg_repository_plugin --> pkg_mcp_client pkg_repository_plugin --> pkg_paths @@ -1184,7 +1192,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1213,6 +1221,7 @@ flowchart TD | [`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-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | 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..87e9a2a526 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.subagents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The one globally named follow-up tool over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -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..9524184414 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -39,6 +39,8 @@ 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_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 +81,7 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `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..76cac9cbb8 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -96,11 +96,17 @@ config: providerName: fork +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -108,6 +114,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 4e849d7835..3e292699d1 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -23,6 +23,7 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 # Re-pin the recorded model: cordis.yml ships deepseek-v4-pro, but this # scenario's corpus was captured on flash. A config patch replaces the diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index 25b0ee8e38..1af96e9283 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -10,4 +10,5 @@ config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 2 diff --git a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml new file mode 100644 index 0000000000..7ce0733e53 --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless counterpart to subagent-durability-failure.cordis.yml: replace the +# live adapter with replay and fail the provider-owned final child checkpoint. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/subagent-durability-failure.cordis.yml b/examples/acp-agent/subagent-durability-failure.cordis.yml new file mode 100644 index 0000000000..c033c323dc --- /dev/null +++ b/examples/acp-agent/subagent-durability-failure.cordis.yml @@ -0,0 +1,10 @@ +# Snapshot-only durability-failure overlay. The child turn's ordinary flush +# succeeds; the provider-owned final confirmation fails deterministically. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-durability-failure + name: './tests/fixtures/subagent-durability-failure.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b652f09931..c2e0328fab 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -41,6 +41,9 @@ const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url)) +const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( + new URL('../subagent-durability-failure.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -213,6 +216,16 @@ 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, a failed final durability + // confirmation reaches task_output with its diagnosis, and send_message to + // an unknown subagent id starts a follow-up task that settles unavailable. + { + name: 'subagent-continuable', + hasModelTurn: true, + recorded: false, + configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, + }, { name: 'subagent-depth-two-rejection', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts new file mode 100644 index 0000000000..47f96c0b80 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -0,0 +1,52 @@ +import type { Context } from 'cordis' + +export const name = 'subagent-durability-failure' +export const inject = ['sessionPersistence', 'tasks'] + +const UNKNOWN_CHILD_ID = '22222222-2222-4222-8222-222222222222' +const FOLLOW_UP_TASK_ID = 'subagent-2' + +/** Fail the child checkpoint and stabilize the authored follow-up failure ordering. */ +export function apply(ctx: Context): void { + const thirdStepEnded = Promise.withResolvers() + const followUpSettled = Promise.withResolvers() + const persistence = ctx.sessionPersistence + const load = persistence.load.bind(persistence) + + // The unavailable-child lookup is real asynchronous I/O. Fence it between + // the authored step boundaries so runner speed cannot reorder the exact log. + persistence.load = async (id) => { + if (id === UNKNOWN_CHILD_ID) await thirdStepEnded.promise + return load.call(persistence, id) + } + ctx.effect(() => () => { + persistence.load = load + thirdStepEnded.resolve(undefined) + followUpSettled.resolve(undefined) + }, 'subagent snapshot ordering') + + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined + && event.type === 'step/end' + && event.data.turn === 1 + && event.data.step === 3) { + thirdStepEnded.resolve(undefined) + } + }) + ctx.tasks.onTaskDone((snapshot) => { + if (snapshot.id === FOLLOW_UP_TASK_ID) followUpSettled.resolve(undefined) + }) + ctx.on('agent/step', async (agent, turn, step) => { + if (agent.session.header.parentSession === undefined && turn === 1 && step === 4) { + await followUpSettled.promise + } + }) + + const flushedTurnEnds = new WeakSet() + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + if (session.events.at(-1)?.type !== 'turn/end') return + if (flushedTurnEnds.has(session)) throw new Error('snapshot disk full') + flushedTurnEnds.add(session) + }) +} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 97f0561a37..e386a25eff 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a 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 or stop with task_kill. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to 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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a 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 or stop with task_kill. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -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..00ae670288 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index b61d7bf623..6d052ead19 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -212,7 +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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "parameters": { "type": "object", "properties": { @@ -237,7 +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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 150c53d68e..15b5e8dde6 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a 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 or stop with task_kill. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to 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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a 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 or stop with task_kill. */ run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ @@ -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/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index d264b20da8..4588ebb898 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 43a87440a2..34193d60d1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/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..6124557f08 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 8e093db8bd..d4c004034f 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index beb93c6b53..72c6b74b72 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 6b366448f1..81703a533d 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"2243ae1a-2d65-4f9c-a972-d360b8cc08aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"021a7fcb-3d54-4ed9-8c2c-ca7565599fd8"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464638477,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"22b51be2-4727-4990-96cd-7017c137152e"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464638477,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464638478,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487588943,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/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..15e024fa07 --- /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":"subagent/descriptor","seq":0,"time":1784795691405,"data":{"version":1,"provider":"spawn","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"turn/start","seq":1,"time":1789000000000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":2,"time":1789000000001,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"5eabc0cb-6297-4988-92d9-554fb1cfdab7"},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1789000000001,"data":{"title":"Reply with exactly the word","messageSeqs":[2],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":4,"time":1785517567401,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"57bfffb1-f18b-4e29-aaca-26ecaea51574"},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1785517567401,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1785517567401,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785517567401,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":8,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":9,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":10,"time":1784795691405,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":11,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1785517567410,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":13,"time":1785517567410,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"156cd267-c1e6-4030-b317-dc2936120f4a"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1785517567410,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":15,"time":1785517567411,"data":{"turn":1,"reason":{"kind":"completed"}}} 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..e34205b0f2 --- /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":"(no new output)\n[status: failed, subagent \"33333333-3333-4333-8333-333333333333\" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: snapshot disk full]"}],"isError":false}],"role":"user","id":"ae79571a-fa78-4de0-9614-a10b5223230c"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1785517567419,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":26,"time":1785517567425,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":27,"time":1789000000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_follow_up","name":"send_message","argumentsDelta":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}} +{"type":"assistant/chunk","seq":29,"time":1789000000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}}} +{"type":"assistant/chunk","seq":30,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":31,"time":1785517567430,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":32,"time":1785517567430,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e78130e-5ae7-4ec9-ad34-9e2400a23ef0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"} +{"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}} +{"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}} +{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"} +{"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}} +{"type":"assistant/chunk","seq":40,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}}} +{"type":"assistant/chunk","seq":41,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}} +{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":49,"time":1789000000048,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":50,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":51,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":52,"time":1785517567471,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":53,"time":1785517567471,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fa9e88d9-d89c-4df7-85d5-0e4fd795ae69"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1785517567472,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":55,"time":1785517567472,"data":{"turn":1,"reason":{"kind":"completed"}}} 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..bf0103bbeb 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index 70940f8907..d143e9d82a 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "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 stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.", "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 or stop with task_kill." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 339ba5480d..0a568d3460 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,8 +2,8 @@ {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7cb62d32-ef8e-4d45-9b5e-d2a1fbdbabbd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"c95810d8-2b1e-42b9-9d81-82269ddb0035"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"6dd61dad-f320-4dda-a481-63ee420df9af"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 38774195e1..0a3da81e04 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,6 +37,8 @@ flowchart LR cfg --> plugin_headless_subagent_spawn plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_headless_subagent_fork + plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] + cfg --> plugin_headless_tool_subagent_control plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_headless_tool_subagent plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -70,6 +72,7 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 3a74bd4976..4caf63672f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -86,11 +86,17 @@ config: providerName: fork +# Continuable background children are selected per delegation tool. The +# separately loaded follow-up tool registers the one global `send_message`. +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent name: '@deepseek-ai/dsh-tool-subagent' config: provider: spawn toolName: subagent + backgroundMode: continuable maxDepth: 1 - id: tool-subagent-fork @@ -98,6 +104,7 @@ config: provider: fork toolName: subagent_fork + backgroundMode: continuable maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index d9a20e60c7..ee0434e48c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"85750b5e-389a-4dfb-83e7-3341025692da"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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 /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681625,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 5e7dd32bb6..3cb4cabd02 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2b9d695a-5ba1-4520-8130-d618bc1a4743"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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 /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681788,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 1f9f94fa0c..d1a5852ea4 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"50d7fdd8-0423-43a2-b8f4-4aef2829c82e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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 /** Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n route: \"steered\" | \"started\";\n taskId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n subagentId?: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":5,"time":1785460681498,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index b187ff75cc..28a652be89 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -4,7 +4,7 @@ {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785464685153,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"da0842e3-2231-4abf-a85f-a16acfb0b305"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1785464685153,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":5,"time":1785464685153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"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":"send_message","description":"Send a follow-up message to a background subagent by its subagent id. If it is still working, the message joins its current task; if it has finished, this starts a new task that continues the same subagent conversation. Either way the response arrives through the returned task id — collect it with `task_output`. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a continuable background subagent: you receive its stable subagent id and current task id; collect the result with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a continuable background subagent and return its subagent and task ids; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":6,"time":1785487564325,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} diff --git a/examples/package.json b/examples/package.json index 97e7918361..bcbb4e21f0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -86,6 +86,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/knip.json b/knip.json index 43102b0b0d..9f2c7db4a3 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,7 @@ "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", + "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f64e05280d..ebef4648df 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -398,7 +398,9 @@ export class BasicCompactService extends CompactService { { owner: null, stability: 'selected-span', - flush: () => this.ctx.sessions.flush(agent.session), + flush: async () => { + await this.ctx.sessions.flush(agent.session) + }, }, signal, ) diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index aadf50f306..6ce3e18f2a 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -217,7 +217,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactService; flushe let flushes = 0 vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => { flushes += 1 - return Promise.resolve() + return Promise.resolve(false) }) return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes } } @@ -730,7 +730,7 @@ describe('compactNow transaction and failure classification', () => { const { ctx, compact } = detachedService() const controller = new AbortController() const reason = new Error('cancelled during flush') - const flushGate = Promise.withResolvers() + const flushGate = Promise.withResolvers() const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise) const session = closedConversation(2) let released = 0 @@ -750,7 +750,7 @@ describe('compactNow transaction and failure classification', () => { expect(settled).toBe(false) expect(released).toBe(0) - flushGate.resolve(undefined) + flushGate.resolve(false) await expect(running).rejects.toBe(reason) expect(released).toBe(1) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 022232cb28..2d126ff5f9 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -44,7 +44,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index acdb7da8f7..1d94399184 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -100,7 +100,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { acceptsNextStep: true, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), updateInbox: () => 'not-found', inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index bedffbf90a..158d96f24d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -179,7 +179,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { status: 'idle', acceptsNextStep: false, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2beb8a3c77..6acad85629 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -743,8 +743,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/** Emit `session/created` exactly once for an {@link enter}ed session (with\n * the carrier {@link enter} captured). Separate from {@link enter} so the\n * caller can yield the detach disposer first (rollback safety — see\n * {@link enter}).\n * @param session - the entered session to announce to listeners.\n * @throws if the session is not live or its announcement already began,\n * including a reentrant call from a creation listener. */', }, { - signature: 'async flush(session: Session): Promise', - jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', + signature: 'async flush(session: Session): Promise', + jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns whether at least one durability listener participated, after every\n * listener has settled successfully.\n * @throws the first registered listener failure after every listener settles.\n */', }, { signature: 'get(id: SessionId): Session | undefined', @@ -882,8 +882,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'Named provider registry and capability-checked start surface.', + summary: 'Named provider registry with raw and Task-backed continuation operations.', methods: [ + { + signature: 'startContinuable(spec: ContinuableStartSpec): ContinuableStart', + jsDoc: '/**\n * Start one durable continuable child through a Task-backed initial\n * activation.\n * @param spec - provider, Task label, and delegation request.\n * @returns the stable child id and initial activation Task id.\n */', + }, + { + signature: 'followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', + jsDoc: '/**\n * Follow up with a continuable child. A live child is steered and fulfillment\n * confirms request admission; an idle child immediately returns a fresh Task\n * whose descriptor lookup, authorization, and cold resume may later fail.\n * @param parent - live direct parent authorizing the operation.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable attribution and caller cancellation; aborting a\n * live-delivery wait cancels the shared activation and awaits quiescence.\n * @returns the existing steered Task or newly started Task.\n * @throws when continuation services are unavailable or live delivery is not admitted.\n */', + }, { signature: 'registerProvider(provider: SubagentProvider): () => void', jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */', @@ -897,7 +905,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * List registered provider names in insertion order.\n * @returns the registered names.\n */', }, { - signature: 'async start(name: string, request: SubagentStartRequest): Promise', + signature: 'async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise', jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', }, ], @@ -1398,7 +1406,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { @@ -1565,7 +1573,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', @@ -1783,6 +1791,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}', @@ -2639,6 +2655,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'SteeringOutcome', + declaration: 'export type SteeringOutcome = {\n readonly status: \'admitted\';\n readonly turn: number;\n readonly step: number;\n} | {\n readonly status: \'rejected\';\n};', + }, + { + name: 'SteeringReceipt', + declaration: 'export interface SteeringReceipt {\n readonly outcome: Promise;\n}', + }, { name: 'StorageForms', declaration: 'export interface StorageForms {\n}', @@ -2651,9 +2675,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: 'SubagentFollowupOptions', + declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'SubagentFollowupResult', + declaration: 'export type SubagentFollowupResult = {\n readonly route: \'steered\';\n readonly taskId: TaskId;\n} | {\n readonly route: \'started\';\n readonly taskId: TaskId;\n};', + }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentProviderStartRequest): Promise;\n resume?(request: SubagentProviderResumeRequest): Promise;\n}', + }, + { + name: 'SubagentProviderResumeRequest', + declaration: 'export interface SubagentProviderResumeRequest {\n readonly sessionId: SessionId;\n readonly prompt: ContentBlock[];\n readonly source: MessageSource;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly descriptor: SubagentDescriptorData;\n}', + }, + { + name: 'SubagentProviderStartRequest', + declaration: 'export interface SubagentProviderStartRequest extends SubagentStartRequest {\n readonly continuation?: SubagentContinuation | undefined;\n}', }, { name: 'SubagentResult', @@ -2661,7 +2709,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n 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[], source: MessageSource): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 71cf5b0ec8..6e9e63a22f 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 79d2865073c89bd88a4d39fafacb5cf60f1fc10c -README.zh.md: 48c4f4900d25f524942abf53c1bc887e7d125cb3 +README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 +README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79d2865073..1662b1076c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -57,6 +57,8 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. +`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it. + Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 48c4f4900d..2fca32a02f 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -57,6 +57,8 @@ interface Config { 统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 +`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。 + 每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8fc9341623..90354f0cc2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -29,6 +29,8 @@ import type { RequestError, RequestErrorAction, SendOptions, + SteeringOutcome, + SteeringReceipt, } from '@deepseek-ai/dsh-agent' import { BlockAssembler, @@ -56,6 +58,27 @@ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } +/** Internal one-shot controller paired with a public steering receipt. */ +interface SteeringDelivery { + readonly receipt: SteeringReceipt + settle(outcome: SteeringOutcome): void +} + +/** Create one idempotent steering-admission controller. */ +function createSteeringDelivery(): SteeringDelivery { + const { promise, resolve } = Promise.withResolvers() + let settled = false + return { + receipt: { outcome: promise }, + settle(outcome): void { + /* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */ + if (settled) return + settled = true + resolve(outcome) + }, + } +} + const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt' /** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */ const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' @@ -112,9 +135,13 @@ function requestProposal(header: EpochHeader): LlmCallConfig { */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { item: InboxItem; wakeup: boolean }[] = [] + private queued: { item: InboxItem; wakeup: boolean; delivery?: SteeringDelivery }[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = [] + private outbox: { message: UserMessage; steering: boolean; item?: InboxItem; delivery?: SteeringDelivery }[] = [] + /** Steering already committed to the log but not yet captured by a request. */ + private pendingAdmissions: SteeringDelivery[] = [] + /** Whether the active cancellation preserves already committed pending delivery. */ + private preservePendingAdmissionsOnAbort = false /** Whether observers see a running interval; consecutive turns share it. */ private busy = false @@ -132,7 +159,6 @@ export class ReactLoopAgent implements Agent { private abort: AbortController | undefined /** Resolves when the current admission and turn exit. */ done: Promise = Promise.resolve() - /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ readonly scope: Scope /** The agent's scoped composition context ({@link Agent.ctx}). */ @@ -166,6 +192,15 @@ export class ReactLoopAgent implements Agent { send( message: UserMessage, options: SendOptions, + ): void { + this.route(message, options) + } + + /** Route one accepted message, optionally tracking steering admission. */ + private route( + message: UserMessage, + options: SendOptions, + delivery?: SteeringDelivery, ): void { const { target, wakeup } = options if (target === 'next-step' && !wakeup) { @@ -184,9 +219,9 @@ export class ReactLoopAgent implements Agent { placement, }) if (placement === 'steering') { - this.outbox.push({ message, steering: true, item }) + this.outbox.push({ message, steering: true, item, ...delivery === undefined ? {} : { delivery } }) } else { - this.queued.push({ item, wakeup }) + this.queued.push({ item, wakeup, ...delivery === undefined ? {} : { delivery } }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers @@ -217,6 +252,7 @@ export class ReactLoopAgent implements Agent { } case 'remove': { this.queued.splice(queuedIndex, 1) + pending.delivery?.settle({ status: 'rejected' }) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) return 'applied' } @@ -234,12 +270,14 @@ export class ReactLoopAgent implements Agent { }) } - /** Steer the open turn, falling back to a waking prompt while idle. */ - steer(input: UserMessage): void { - this.send(input, { + /** Steer the open turn, falling back to a tracked waking prompt while idle. */ + steer(input: UserMessage): SteeringReceipt { + const delivery = createSteeringDelivery() + this.route(input, { target: 'next-step', wakeup: true, - }) + }, delivery) + return delivery.receipt } /** Append model-facing context without waking the driver. */ @@ -293,11 +331,17 @@ export class ReactLoopAgent implements Agent { // inboxes clear; listener failures are contained by the dispatcher. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } + if (options.keepInbox && this.abort !== undefined) this.preservePendingAdmissionsOnAbort = true if (!options.keepInbox) { const discarded = this.queued.map(item => item.item) + for (const item of this.queued) item.delivery?.settle({ status: 'rejected' }) for (const item of this.outbox) { - if (item.steering && item.item !== undefined) discarded.push(item.item) + if (item.steering && item.item !== undefined) { + item.delivery?.settle({ status: 'rejected' }) + discarded.push(item.item) + } } + this.rejectPendingAdmissions() // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 this.outbox.length = 0 @@ -362,7 +406,8 @@ export class ReactLoopAgent implements Agent { // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // oxlint-disable-next-line typescript/no-non-null-assertion - const { item } = this.queued.shift()! + const pending = this.queued.shift()! + const { item, delivery } = pending const { message } = item const inheritedOutboxLength = this.outbox.length @@ -412,6 +457,7 @@ export class ReactLoopAgent implements Agent { // still owns the slot here and releasing it unconditionally is exact. this.abort = undefined if (admitted === undefined) { + delivery?.settle({ status: 'rejected' }) this.acceptsNextStep = false try { this.flushRejectedAdmissionContexts() @@ -429,7 +475,7 @@ export class ReactLoopAgent implements Agent { this.continueOrIdle() return } - await this.run(trigger, admitted, inheritedOutboxLength) + await this.run(trigger, admitted, inheritedOutboxLength, Object.freeze([]), delivery) }) // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation @@ -446,6 +492,7 @@ export class ReactLoopAgent implements Agent { admitted: UserMessage[] = [], inheritedOutboxLength = 0, priorFailures: readonly LlmFailure[] = Object.freeze([]), + promptDelivery?: SteeringDelivery, ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and a retry is entered only after the prior run clears it. @@ -453,6 +500,7 @@ export class ReactLoopAgent implements Agent { if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) const controller = new AbortController() this.abort = controller + this.preservePendingAdmissionsOnAbort = false this.acceptsNextStep = true const signal = controller.signal const turn = this.lastTurn + 1 @@ -476,13 +524,12 @@ export class ReactLoopAgent implements Agent { // Context or steering retained by an earlier rejected admission happened // before this prompt and must occupy the same order in durable history. this.drainOutbox(turn, inheritedOutboxLength) + if (promptDelivery !== undefined) this.pendingAdmissions.push(promptDelivery) for (const input of admitted) { this.session.append('user/message', input, { surfaceOp: 'append' }) } signal.throwIfAborted() - this.drainOutbox(turn) - steps: while (true) { step += 1 const outcome = await this.step(turn, step, signal) @@ -490,11 +537,15 @@ export class ReactLoopAgent implements Agent { case 'completed': requestFailureHistory = Object.freeze([]) if (outcome.maxTokens) reason = { kind: 'max-tokens' } - // A concluding tool result is terminal: steering already in the - // log waits for the next turn's request instead of reopening this - // one, and the agent/turn-stopping drain below is skipped for the same - // reason. - if (outcome.concluded) break steps + // A concluding tool result is terminal: reject steering that did + // not enter a request, while retaining same-boundary context in + // durable history before the turn closes. + if (outcome.concluded) { + this.discardOutboxSteering() + this.drainOutbox(turn) + break steps + } + /* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */ if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue break case 'request-failed': { @@ -530,7 +581,10 @@ export class ReactLoopAgent implements Agent { } await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() - if (!this.drainOutbox(turn)) break + this.drainOutboxContexts() + if (!this.outbox.some(item => item.steering)) { + break + } } } catch (caught: unknown) { try { @@ -568,6 +622,10 @@ export class ReactLoopAgent implements Agent { // is still this run's controller here. this.abort = undefined signal.removeEventListener('abort', cancelRetry) + const preservePending = signal.aborted && this.preservePendingAdmissionsOnAbort + this.preservePendingAdmissionsOnAbort = false + // oxlint-disable-next-line typescript/no-unnecessary-condition -- keepInbox cancellation can set this while turn work is awaited. + if (!preservePending) this.rejectPendingAdmissions() } if (opened) { @@ -606,10 +664,6 @@ export class ReactLoopAgent implements Agent { await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) signal.throwIfAborted() - // Take the outbox whole — same-boundary steering and context leave in - // this request together. - this.drainOutbox(turn) - // Assemble request-owned prompt inputs fresh each step. Dynamic context is // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. @@ -618,12 +672,18 @@ export class ReactLoopAgent implements Agent { const system = renderPrompt(assembly) materializeRuntimeContext(session, renderContextSnapshot(assembly)) + // Commit the exact pending batch only after every asynchronous + // pre-request contribution succeeded. Input accepted after this splice + // remains pending for a later request. + this.drainOutbox(turn) + // Snapshot the exact log prefix: the reconstruction boundary. Appends // after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) this.stepOpen = true + this.admitPendingAdmissions(turn, step) signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( @@ -690,14 +750,14 @@ export class ReactLoopAgent implements Agent { )) } - // Tool results stay adjacent to their calls; input accepted during the - // request enters the log only after the complete result batch. - const steered = this.drainOutbox(turn) + // Ordinary context keeps the base loop's result-adjacent commit point. + // Steering remains provisional until the next request snapshot admits it. + this.drainOutboxContexts() session.append('step/end', { turn, step }) this.stepOpen = false return { kind: 'completed', - continueTurn: (toolCalls.length > 0 && !concluded) || steered, + continueTurn: (toolCalls.length > 0 && !concluded) || this.outbox.some(item => item.steering), concluded, maxTokens: finish.kind === 'max-tokens', } @@ -802,25 +862,83 @@ export class ReactLoopAgent implements Agent { return { request, ...preparedCall === undefined ? {} : { preparedCall } } } - /** Commit the outbox and report whether it contained steering. */ - private drainOutbox(turn: number, limit = this.outbox.length): boolean { - let steered = false - for (const item of this.outbox.splice(0, limit)) { - if (item.steering) { - steered = true - /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ - if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) - this.session.append( - 'steering/message', - { turn, message: item.message }, - { surfaceOp: 'append' }, - ) - } else { - this.session.append('user/message', item.message, { surfaceOp: 'append' }) + /** Commit one stable outbox prefix and retain tracked delivery until snapshot admission. */ + private drainOutbox(turn: number, limit = this.outbox.length): void { + const batch = this.outbox.splice(0, limit) + for (let index = 0; index < batch.length; index += 1) { + const item = batch[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during drain`) + try { + if (item.steering) { + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) + this.session.append( + 'steering/message', + { turn, message: item.message }, + { surfaceOp: 'append' }, + ) + if (item.delivery !== undefined) this.pendingAdmissions.push(item.delivery) + } else { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } + } catch (error: unknown) { + item.delivery?.settle({ status: 'rejected' }) + this.outbox.unshift(...batch.slice(item.steering ? index + 1 : index)) + throw error } } - return steered + } + + /** Commit ordinary context while retaining provisional steering in order. */ + private drainOutboxContexts(): void { + const pending = this.outbox + this.outbox = [] + for (let index = 0; index < pending.length; index += 1) { + const item = pending[index] + /* v8 ignore next -- the index walks the exact array length. */ + if (item === undefined) throw new Error(`agent "${this.id}" outbox item disappeared during context drain`) + if (item.steering) { + this.outbox.push(item) + continue + } + try { + this.session.append('user/message', item.message, { surfaceOp: 'append' }) + } catch (error: unknown) { + this.outbox.push(...pending.slice(index)) + throw error + } + } + } + + /** Settle every committed steering item captured by this immutable request. */ + private admitPendingAdmissions(turn: number, step: number): void { + const outcome: SteeringOutcome = { status: 'admitted', turn, step } + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle(outcome) + } + + /** Reject committed steering that left the inbox without reaching a request. */ + private rejectPendingAdmissions(): void { + for (const delivery of this.pendingAdmissions.splice(0)) delivery.settle({ status: 'rejected' }) + } + + /** Discard uncommitted steering while retaining same-boundary injected context. */ + private discardOutboxSteering(): void { + const contexts: typeof this.outbox = [] + const discarded: InboxItem[] = [] + for (const item of this.outbox) { + if (!item.steering) { + contexts.push(item) + continue + } + item.delivery?.settle({ status: 'rejected' }) + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + discarded.push(item.item) + } + this.outbox = contexts + if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 295117d7d3..dfa0a91098 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -50,10 +50,11 @@ describe('Agent', () => { }])).toBeUndefined() expect(call('inject', [message('context')])).toBeUndefined() expect(call('followup', [message('followup')])).toBeUndefined() - expect(call('steer', [message('steering')])).toBeUndefined() + const receipt = agent.steer(message('steering')) await agent.whenIdle() expect(adapter.requests).toHaveLength(3) + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 }) }) it('idle inject() appends context without opening a turn or requesting a flush', async () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index a362db8552..15bfa7e0fc 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -470,7 +470,10 @@ describe('agent loop', () => { parameters: {}, async execute() { // steer while the turn is running (during tool execution) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })) + agent.send( + createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }), + { target: 'next-step', wakeup: true }, + ) return [{ type: 'text', text: 'tool done' }] }, })) @@ -544,6 +547,120 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) + it('rejects failed steering commits while preserving later context', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'rejected steering' }], + source: { kind: 'user' }, + })) + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'steering/message' && !rejected) { + rejected = true + throw new Error('reject steering commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).not.toContain('rejected steering') + }) + + it('rejects committed steering when the step boundary fails', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'committed steering' }], + source: { kind: 'user' }, + })) + }) + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'step/start') throw new Error('reject step boundary') + }) + + send(agent, 'prompt') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true) + expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + }) + + it('retries context and steering after a context commit fails', async () => { + const adapter = new MockAdapter([textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + ctx.on('agent/step', (subject) => { + if (subject !== agent || receipt !== undefined) return + subject.inject(createUserMessage({ + content: [{ type: 'text', text: 'preserved context' }], + source: { kind: 'plugin', plugin: 'loop-test' }, + })) + receipt = subject.steer(createUserMessage({ + content: [{ type: 'text', text: 'preserved steering' }], + source: { kind: 'user' }, + })) + }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string; data?: { source?: { kind: string } } } + if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) { + rejected = true + throw new Error('reject context commit') + } + }) + + send(agent, 'first prompt') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + + send(agent, 'recover') + await waitForIdle(ctx, agent) + + if (receipt === undefined) throw new Error('agent/step did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 }) + expect(adapter.requests).toHaveLength(1) + const request = JSON.stringify(adapter.requests[0]?.messages) + expect(request).toContain('preserved context') + expect(request).toContain('preserved steering') + }) + it('inject() while idle appends context without opening a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -721,13 +838,24 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let receipt: ReturnType | undefined + let contextInjected = false + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'step/end' || contextInjected) return + contextInjected = true + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'final context' }], + source: { kind: 'plugin', plugin: 'finalize' }, + })) + }) ctx.tools.register(defineContentToolFixture({ name: 'finalize', description: '', parameters: {}, async execute(_args, exec) { - // Steering lands while the concluding tool is still executing. - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) + // Steering lands while the concluding tool is still executing; the + // step/end listener adds ordinary context after the normal result drain. + receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } })) exec.concludeTurn() return [{ type: 'text', text: 'final' }] }, @@ -740,9 +868,12 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) const events = agent.session.events.map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) - // The steering is durable inside the concluded turn and feeds the NEXT - // turn's request instead of being dropped or re-queued. - expect(events).toContain('steering/message') + if (receipt === undefined) throw new Error('concluding tool did not submit steering') + expect(await receipt.outcome).toEqual({ status: 'rejected' }) + expect(events).not.toContain('steering/message') + expect(agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true) send(agent, 'follow up') await waitForIdle(ctx, agent) @@ -751,7 +882,8 @@ describe('agent loop', () => { .flatMap(message => message.content) .filter(block => block.type === 'text') .map(block => block.text) - expect(texts).toContain('late steering') + expect(texts).toContain('final context') + expect(texts).not.toContain('late steering') }) it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index e94f507b99..78473df4a7 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 8799bc3664b2137b386b752f905e1414fb770cb9 -README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd +README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 +README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8799bc3664..8a60283521 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -64,7 +64,7 @@ The handle every plugin programs against: - `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. - `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. +- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. - `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement. - `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 851c174ba8..ffa71ea987 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -64,7 +64,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, - `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 - `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 -- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 +- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 - `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。 - `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 525653776f..1a1010e90e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -58,6 +58,20 @@ export type InboxAction = /** Result of applying an inbox action at the synchronous ownership boundary. */ export type InboxActionResult = 'applied' | 'not-found' +/** Final admission outcome for one call to {@link Agent.steer}. */ +export type SteeringOutcome = + | { readonly status: 'admitted'; readonly turn: number; readonly step: number } + | { readonly status: 'rejected' } + +/** + * Message-owned steering admission receipt. The outcome promise always + * resolves: synchronous input validation still throws from {@link Agent.steer}, + * while lifecycle policy reports non-admission as `rejected`. + */ +export interface SteeringReceipt { + readonly outcome: Promise +} + /** * Options for the unified {@link Agent.send} primitive over the * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} @@ -225,16 +239,18 @@ export interface Agent { followup(message: UserMessage): void /** - * Submit steering during prompt admission or an open turn — the - * `next-step`/wakeup preset of {@link send}. It stages for the next steering - * checkpoint before a request or stop decision. If the activity fails before - * that boundary, the remainder stays staged without waking the agent; retry - * or a later prompt takes it. Outside that window steering falls back to a - * woken follow-up turn, while cancellation or disposal may discard pending - * steering. + * Submit steering with a message-owned admission receipt — the + * `next-step`/wakeup preset of {@link send}. During prompt admission or an + * open turn, the message waits in the steering FIFO until a committed step + * snapshots it; outside that window it enters the ordinary queued FIFO. The + * receipt resolves `admitted` only after the message joins that step's + * immutable request history, or `rejected` when terminal policy, + * cancellation, or disposal discards it first. A non-terminal turn close may + * leave it staged for a later admitted prompt without settling the receipt. * @param message - identified steering content and its producer provenance. + * @returns the receipt for this exact message's eventual admission outcome. */ - steer(message: UserMessage): void + steer(message: UserMessage): SteeringReceipt /** * Append model-facing context without running the model — the diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 8d586313b6..09f3af6cff 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -26,7 +26,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, reserveTurnAdmission: () => undefined, cancel() {}, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 3dc9877266..00260f89cb 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 9c7d41901e6fb0133fff0e210260e5310a025f75 -README.zh.md: ca1292289901a09b83f9b0a794fa4edc9754b1da +README.md: 4730cac913e949d642d049a5c53ab2dd47e10627 +README.zh.md: 12aa1625d7b0568196cd788c2a25cfb869d8780d diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 9c7d41901e..4730cac913 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -13,7 +13,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. -- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index ca12922899..12aa1625d7 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -13,7 +13,7 @@ ### 公共 API - `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 -- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。 +- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 8bbf783bc2..ad63179156 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -93,8 +93,7 @@ declare module 'cordis' { 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited parallel durability checkpoint: every listener runs and the - * caller awaits all of them, with no waterfall veto. Dispatch through - * {@link SessionStore.flush}. Scope-filtered dispatch + * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @dshScopeScan unsupported @@ -969,10 +968,11 @@ export class SessionStore extends Service { * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; after all settle, - * rejects with the first registered listener failure if any listener failed. + * @returns whether at least one durability listener participated, after every + * listener has settled successfully. + * @throws the first registered listener failure after every listener settles. */ - async flush(session: Session): Promise { + async flush(session: Session): Promise { const { carrier } = this.liveEntryFor(session) const callbackArgs: unknown[] = [session] const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session]) @@ -988,6 +988,7 @@ export class SessionStore extends Service { })) const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') if (failure !== undefined) throw failure.reason + return callbacks.length > 0 } /** Return the exact live entry; detached/prepared objects reject. */ diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 7a5e617254..ffa436bc8b 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -80,6 +80,24 @@ describe('session dispatch carriers', () => { }) describe('sessions.flush()', () => { + it('allows an ordinary flush with no listeners', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + + await expect(ctx.sessions.flush(session)).resolves.toBe(false) + }) + + it('reports a participating listener after it succeeds', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + const flushed: Session[] = [] + ctx.on('session/flush', current => void flushed.push(current)) + + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + + expect(flushed).toEqual([session]) + }) + it('dispatches session/flush with the owning carrier and awaits all listeners', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') diff --git a/packages/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/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 797263897f..1800b1c3fd 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -36,7 +36,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1da1e0da48..31d7f5c8d3 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -40,7 +40,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { appendInjection(session, input) }, reserveTurnAdmission: () => undefined, cancel() { status = 'idle' }, diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 82793618c4..c7886fac0f 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -50,7 +50,7 @@ function stubAgentForSession(session: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { if (shouldDefer) deferred.push(input) else appendInjection(session, input) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 308395a0a1..7b12d76f59 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -41,7 +41,7 @@ function liveAgent(ctx: Context, session: Session): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input: UserMessage) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index e68b9f73bb..4278fc5a50 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -35,7 +35,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index da05a4cd9b..c59dcf7e64 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -48,7 +48,7 @@ function stubAgent(session: Session): Agent { acceptsNextStep: false, ctx: new Context(), followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index f04fff74ba..d9d8aa16d1 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -45,7 +45,7 @@ function agent(ctx: Context, cwd?: string): Agent { options: {}, session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -301,7 +301,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 46c4f1f6c9..6fa7804980 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 08bd39c28a..0b91d5a88c 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -29,7 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index cca0554595..b1bc368ab2 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -46,7 +46,7 @@ function agent(ctx: Context, cwd: string): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 2c767de795..10f2b369a2 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { acceptsNextStep: false, ctx: scope.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index d5164261c7..d6b4a08968 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index bef549d483..a25f97ccd0 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 09fe8eb5b7..2aa6c40e71 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -209,7 +209,14 @@ config: id: 'subagent', summary: 'Delegate work to child agents', mode: 'multiple', - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }], + // In-process options select continuable background delegation; the + // follow-up adapter remains an independently loadable global tool. + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' }, + { kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' }, + { kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }, + { kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' }, + ], options: [ { id: 'spawn', @@ -221,7 +228,7 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'spawn' } satisfies ToolSubagentConfig, + config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig, }, ], }, @@ -234,7 +241,11 @@ config: kind: 'npm-cordis-config-entry', id: 'tool-subagent-fork', package: '@deepseek-ai/dsh-tool-subagent', - config: { provider: 'fork', toolName: 'subagent_fork' } satisfies ToolSubagentConfig, + config: { + provider: 'fork', + toolName: 'subagent_fork', + backgroundMode: 'continuable', + } satisfies ToolSubagentConfig, }, ], }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 648ec11428..763558afab 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -204,6 +204,17 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') }) + it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => { + const project = await createCommitted([selection('subagent', [option])]) + expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local') + expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks') + expect(project.packageManifest().dependencies).toMatchObject({ + '@deepseek-ai/dsh-tasks-local': '^0.0.1', + '@deepseek-ai/dsh-tool-tasks': '^0.0.1', + }) + expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') + }) + it('round-trips embed app projects without a front-door Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index 9108a29c14..cf5722ff29 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,5 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/step', (agent): Promise => ctx.sessions.flush(agent.session)) + ctx.on('agent/step', async (agent): Promise => { + await ctx.sessions.flush(agent.session) + }) } diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index d8ee35f4b7..971eed79c6 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -719,7 +719,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(reuse)).resolves.toBe(true) reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(reuse) @@ -796,7 +796,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(live)).resolves.toBe(true) const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) // Seeded 0-5 plus the constructor's end-seed event at 6. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index bca32438f4..eb80683431 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -279,7 +279,7 @@ describe('PersistenceCoordinator eager writes', () => { const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)] appendGate.resolve(true) - await expect(Promise.all(barriers)).resolves.toEqual([undefined, undefined]) + await expect(Promise.all(barriers)).resolves.toEqual([true, true]) expect(backend.appendAttempts).toBe(2) expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) } finally { @@ -353,7 +353,7 @@ describe('PersistenceCoordinator stored identity', () => { expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) const resumed = ctx.sessions.create(id, { seed: loaded.events, meta: loaded.meta }) - await expect(ctx.sessions.flush(resumed)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(resumed)).resolves.toBe(true) } finally { loadGate.resolve(true) await fiber.dispose() @@ -592,7 +592,7 @@ describe('PersistenceCoordinator retirement', () => { const reuseFlush = ctx.sessions.flush(reuse) loadGate.resolve(true) - await expect(reuseFlush).resolves.toBeUndefined() + await expect(reuseFlush).resolves.toBe(true) } finally { loadGate.resolve(true) await backendFiber.dispose() diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ce92c12b39..df8f4e6fed 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -49,7 +49,7 @@ function agentForCwd(cwd: string): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, @@ -70,7 +70,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { send: () => {}, updateInbox: () => 'not-found', followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index a875878075..bead24d34c 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/README.md -README.md: fed0c3d6b252f5eeb8355c3b544066765999120a -README.zh.md: 9bc187aa972bc92385d32fe787e2fd65b6ce8361 +README.md: a195ecbaeb24cb63af8cdd4ac872bb6a2fc97d46 +README.zh.md: b9965030a38b603f7c03d98d6b8021acbeb47fda diff --git a/packages/subagent/README.md b/packages/subagent/README.md index fed0c3d6b2..a195ecbaeb 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -6,14 +6,15 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| -| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent/` | Subagent service: named-provider registry, vocabulary, durable descriptor, and optional Task-backed continuation orchestration | `ctx.subagents` | | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | -| `subagent-spawn/` | In-process backend: a fresh child agent | (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`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | +| `tool-subagent-control/` | The optional, globally named `send_message` follow-up tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface 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 and continuation orchestration live at `subagent/subagent/`. Raw `start` / `resume` dispatch stays independent of Tasks and persistence; an internal manager binds durable child sessions to disposable Task-backed activations only while the Task and Agent services are present, and resolves persistence only when a continuation operation runs. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. -The 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 design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 9bc187aa97..b9965030a3 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -6,14 +6,15 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age | 包(package) | 角色 | ctx 键 | |---|---|---| -| `subagent/` | 抽象 subagent seam:具名提供方注册表与词汇 | `ctx.subagents` | +| `subagent/` | Subagent 服务:具名提供方注册表、词汇、持久化描述符与可选的由 Task 支撑的继续执行编排 | `ctx.subagents` | | `subagent-inprocess/` | 共享进程内运行驱动器(不含提供方;每次运行使用一个清理 effect) | 无 | -| `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | -| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | +| `subagent-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`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | +| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 后续消息工具 | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口和继续执行编排位于 `subagent/subagent/`。原始 `start` / `resume` 分发仍与 Task 和持久化无关;只有在 Task 与 Agent 服务存在时,内部管理器才会把持久化子会话绑定到可 dispose、由 Task 支撑的 activation,并且只在继续执行操作运行时解析持久化服务。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 -提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/subagent-fork/README.i18n.yaml b/packages/subagent/subagent-fork/README.i18n.yaml index 14b40beb88..317762e160 100644 --- a/packages/subagent/subagent-fork/README.i18n.yaml +++ b/packages/subagent/subagent-fork/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork/README.md -README.md: b448dc309bff07c744443530a648c7c30e4d20d9 -README.zh.md: 3e14206d5637fded9edb4e173608c55e3341f8fc +README.md: 55475aee7841e91960de79887dfe9bf37afdf9da +README.zh.md: 3eec8cb51a47243a1f06416a3f8f99ae8df8e734 diff --git a/packages/subagent/subagent-fork/README.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/README.zh.md b/packages/subagent/subagent-fork/README.zh.md index 3e14206d56..3eec8cb51a 100644 --- a/packages/subagent/subagent-fork/README.zh.md +++ b/packages/subagent/subagent-fork/README.zh.md @@ -57,5 +57,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index a96ce4f06e..8461795120 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -11,8 +11,13 @@ 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, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} 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 @@ -59,7 +64,7 @@ class ForkProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(request, { // Only pass a seed when there's a completed turn to inherit; an empty seed @@ -67,6 +72,13 @@ class ForkProvider implements SubagentProvider { ...seed.length > 0 ? { seed } : {}, }) } + + resume(request: SubagentProviderResumeRequest) { + // Cold resume loads the child's OWN persisted transcript, which already + // contains the completed-turn prefix captured at initial creation; it + // never forks the parent's newer history again. + return resumeInProcessRun(request) + } } 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..25b886b635 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md -README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f -README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae +README.md: 8d266e93021285e27e7819386a4de9c33492a796 +README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 980bc18de0..8d266e9302 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, confirmed steering, and disposal—has one implementation here. ## Start contract @@ -11,21 +11,28 @@ 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 prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records. +5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior. +6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +## Cold resume + +`resumeInProcessRun(request): Promise` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, final durability confirmation, abort handoff, and disposal follow the same contract as a continuable start. + ## Cancellation and ownership The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child. After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. +Runs expose confirmed `steer`: a synchronous status check prevents the Agent-level idle fallback from starting an untracked turn, then the run submits through `Agent.steer()` and awaits that exact message's receipt. Fulfillment means a committed child request snapshot admitted the message; terminal turn policy, cancellation, disposal, or a settlement race rejects instead. A synchronously visible structured capture is rejected before submission because its terminal outcome is already authoritative. The run never falls through from rejected live delivery to a later queued turn or cold resume. + ## Spawn and fork inputs `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. @@ -110,5 +117,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..79450a32a7 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,28 @@ 驱动器按以下顺序运行: 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/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。 +5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。 +6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +## 冷恢复 + +`resumeInProcessRun(request): Promise` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、最终持久性确认、中止交接和 dispose 遵循与可继续启动相同的契约。 + ## 取消与所有权 必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。 兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。 +运行公开确认式 `steer`:同步状态检查会阻止 Agent 层的空闲 fallback 启动未跟踪轮次,随后运行通过 `Agent.steer()` 提交消息,并等待该准确消息的回执。兑现表示某个已提交的子 agent 请求 snapshot 接纳了消息;结束轮次的策略、取消、dispose(资源释放)或结算竞态会改为拒绝。已同步可见的结构化捕获会在提交前被拒绝,因为其终态结果已经具有权威性。实时投递被拒绝后,运行绝不会转而进入之后的排队轮次或冷恢复。 + ## Spawn 与 fork 输入 `InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。 @@ -110,5 +117,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..38fd418f39 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 { createUserMessage, errorChain, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' +import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent' +import type { + SubagentDescriptorData, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, + SubagentResult, + SubagentRun, + 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. @@ -60,21 +67,48 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } +/** Whether one activation must prove its final state durable before success. */ +type Durability = 'best-effort' | 'required' + +/** Activation-specific inputs to the shared in-process driver. */ +interface DriveTurnOptions { + readonly durability: Durability + /** Attribution for a resumed activation's follow-up prompt. */ + readonly source?: MessageSource + readonly structured?: StructuredAttachment +} + /** Error used when cancellation wins before the child publication boundary. */ function prePublicationAbort(): Error { return new Error('subagent request was aborted before child publication') } +/** + * Register the one-shot child-scoped contribution that appends the durable + * `subagent/descriptor` event. The prepended `agent/prompt-submit` wrapper + * appends before downstream admission can block or throw. Allowed admission + * opens the initial turn afterward; the final required checkpoint also + * persists the descriptor when no turn opens. + */ +function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { + childCtx.once('agent/prompt-submit', (agent, _message, _signal, next) => { + agent.session.append('subagent/descriptor', descriptor) + return next() + }, { prepend: true }) +} + /** * Establish and drive one in-process child. Fulfillment means the agent is * already published in the registry; rejection means the agent factory's * creation transaction and any partially-created child have reached quiescence. + * A `request.continuation` publishes exactly its stable child id and appends + * its descriptor before the child's initial prompt admission. * @param request - the trusted typed start request, including its required signal. * @param options - the optional fork seed. * @returns a ready holder-owned run. */ export async function startInProcessRun( - request: SubagentStartRequest, + request: SubagentProviderStartRequest, options: InProcessRunOptions, ): Promise { assertSubagentMaxDepth(request.maxDepth) @@ -88,7 +122,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 +159,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 +178,120 @@ export async function startInProcessRun( signal: request.signal, setup, }) - 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() + return driveTurn( + handle, + request.signal, + request.prompt, + childId, + seedLength, + { + durability: request.continuation === undefined ? 'best-effort' : 'required', + ...structured === undefined ? {} : { structured }, + }, + ) +} + +/** + * Reconstruct a persisted continuable child under the live parent's scope and + * drive one follow-up turn. The resumed session's own transcript is the seed + * (loaded through the parent's persistence-backed registry `resume`), so a + * fork child never re-forks current parent history; the persisted header + * remains authoritative for lineage and the delegation-depth floor. + * @param request - the fully resolved resume request from the continuation manager. + * @returns a fresh ready holder-owned run for this activation. + */ +export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise { + if (request.signal.aborted) throw prePublicationAbort() + const descriptor = request.descriptor + const agentOptions: AgentOptions = { + ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, + ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + } + const setup = (childCtx: Context): void => { + if (descriptor.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona }) + } + if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter) } + const handle = await request.parent.ctx.agents.resume({ + resumeSessionId: request.sessionId, + agentOptions, + signal: request.signal, + setup, + }) + // The result boundary is this activation's own work: everything already in + // the resumed transcript belongs to earlier turns. + const resumePoint = handle.agent.session.events.length + return driveTurn( + handle, + request.signal, + request.prompt, + request.sessionId, + resumePoint, + { durability: 'required', source: request.source }, + ) +} + +/** + * Drive one activation turn on a published child and wrap it as a run. The + * caller has already created or resumed the agent; this owns the + * signal-handoff race, the live abort listener, result collection past + * `boundary`, the continuable-run durability confirmation, confirmed + * steering, and disposal. + */ +function driveTurn( + handle: AgentHandle, + signal: AbortSignal, + prompt: ContentBlock[], + childId: SessionId, + boundary: number, + options: DriveTurnOptions, +): SubagentRun | Promise { + const child = handle.agent + const { durability, source, structured } = options + // Agent creation detaches its creation-only abort listener before returning. + // Close the narrow handoff race before installing the live-run listener. + if (signal.aborted) { + 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: source ?? { kind: 'user' } })) await child.whenIdle() + if (durability === 'required') { + try { + const participated = await child.ctx.sessions.flush(child.session) + if (!participated) { + throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`) + } + } catch (error: unknown) { + if (!signal.aborted) { + throw new SubagentError( + `subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`, + 'DURABILITY_FAILED', + { cause: error }, + ) + } + } + } return readResult( child, - seedLength, + boundary, flags.cancelled, structured ? { captured: structured.captured() } : undefined, + durability === 'required' && signal.aborted, ) } finally { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) } })() @@ -178,29 +300,49 @@ export async function startInProcessRun( localAgent: child, result, dispose(): Promise { - request.signal.removeEventListener('abort', onAbort) + signal.removeEventListener('abort', onAbort) flags.cancelled = true return handle.dispose() }, + async steer(content: ContentBlock[], steeringSource: MessageSource): Promise { + // The status check and submission share one synchronous frame. An idle + // Agent.steer() would queue an untracked turn after this run's result. + if (child.status !== 'running') { + throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) + } + // Avoid waiting for the structured terminal checkpoint when its outcome + // is already authoritative and synchronously visible. + if (structured?.captured() !== undefined) { + throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) + } + const receipt = child.steer(createUserMessage({ content, source: steeringSource })) + const outcome = await receipt.outcome + if (outcome.status === 'rejected') { + throw new Error(`subagent child "${childId}" stopped before steering admission; the message was not delivered`) + } + }, } } -/** 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 }, + cancellationOwnsCompleted = false, ): 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 ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary - // `aborted` end, yielding `disposed` instead. A requested cancellation owns - // every non-completed in-flight outcome; a turn already completed stays so. - const stopReason: SubagentStopReason = cancelled && recorded !== 'completed' + // `aborted` end, yielding `disposed` instead. Activation cancellation during + // its final durability checkpoint also owns a recorded completed turn because + // the provider has not published that result yet. + const stopReason: SubagentStopReason = cancelled + && (recorded !== 'completed' || cancellationOwnsCompleted) ? 'aborted' : recorded if (structured !== undefined) { diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index ff6db01285..d3396cd21a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,6 +120,28 @@ describe('in-process structured output', () => { await run.dispose() }) + it('confirmed steering rejects delivery once the structured result is captured', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + // oxlint-disable-next-line prefer-const -- single assignment follows listener registration so pre-fulfillment events remain guardable. + let run: Awaited> | undefined + let delivery: Promise | undefined + ctx.on('session/event', (session, event) => { + if (session.header.parentSession === undefined || run === undefined + || event.type !== 'tool/result' || delivery !== undefined) return + delivery = run.steer?.([{ type: 'text', text: 'one more thing' }], { kind: 'user' }) + void delivery?.catch(() => undefined) + }) + run = await ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + if (delivery === undefined) throw new Error('structured result did not submit steering') + await expect(delivery) + .rejects.toThrow(/already reported its structured result; the message was not delivered/) + expect(result.structured).toEqual({ answer: 7 }) + await run.dispose() + }) + it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => { // One model response carrying structured_output FIRST and a side-effecting // call after it: the continuation veto only fires at step end, so without diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index fb631ee10e..530720596f 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -2,16 +2,17 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SubagentService from '@deepseek-ai/dsh-subagent' -import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { startInProcessRun } from '../src/index.ts' +import SubagentService, { SUBAGENT_DESCRIPTOR_VERSION, SubagentError } from '@deepseek-ai/dsh-subagent' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { resumeInProcessRun, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -38,6 +39,22 @@ function request(parent: Agent, signal = new AbortController().signal) { return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal } } +function continuableRequest(parent: Agent) { + const sessionId = SessionId('continuable-child') + return { + ...request(parent), + continuation: { + sessionId, + descriptor: { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'mock', + agentModel: 'mock', + }, + }, + } +} + function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } @@ -56,6 +73,139 @@ describe('startInProcessRun', () => { expect(ctx.agents.get(run.id)).toBeUndefined() }) + it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => { + const { ctx } = await setup([textResponse('driver answer')]) + const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' }) + const run = await startInProcessRun({ + ...request(parent), + agentOptions: { provider: 'mock', model: 'mock' }, + }, {}) + + const child = ctx.agents.get(run.id)! + expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' }) + expect(child.session.header.cwd).toBe('/workspace') + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + await run.dispose() + }) + + it('rejects a continuable child when no durability listener is registered', async () => { + const { parent } = await setup([textResponse('driver answer')]) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') + await run.dispose() + }) + + it('rejects when the durability listener disappears before final confirmation', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + let detach = (): void => {} + detach = ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes === 1) detach() + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.message).toContain('required durability checkpoint has no registered listener') + expect(flushes).toBe(1) + await run.dispose() + }) + + it('requires a final durability checkpoint for a continuable child', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const failure = new Error('disk full') + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + throw failure + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + const caught: unknown = await run.result.catch((error: unknown) => error) + expect(caught).toBeInstanceOf(SubagentError) + const durabilityError = caught as SubagentError + expect(durabilityError.code).toBe('DURABILITY_FAILED') + expect(durabilityError.cause).toBe(failure) + expect(durabilityError.message).toContain( + 'the latest child state was not confirmed persisted and may be unavailable or stale on resume: disk full', + ) + expect(flushes).toBe(2) + await run.dispose() + }) + + it('completes a continuable child when the final checkpoint retries a transient flush failure', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes === 1) throw new Error('temporary append failure') + }) + + const run = await startInProcessRun(continuableRequest(parent), {}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(flushes).toBe(2) + await run.dispose() + }) + + it.each([ + { checkpoint: 'succeeds', failure: undefined }, + { checkpoint: 'fails', failure: new Error('disk full') }, + ])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const checkpointStarted = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes !== 2) return + checkpointStarted.resolve(undefined) + await releaseCheckpoint.promise + if (failure !== undefined) throw failure + }) + const controller = new AbortController() + + const run = await startInProcessRun({ + ...continuableRequest(parent), + signal: controller.signal, + }, {}) + await checkpointStarted.promise + controller.abort() + releaseCheckpoint.resolve(undefined) + + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + expect(flushes).toBe(2) + await run.dispose() + }) + + it('keeps foreground runs best-effort when their turn checkpoint fails', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + let flushes = 0 + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined) return + flushes++ + throw new Error('disk full') + }) + + const run = await startInProcessRun(request(parent), {}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(flushes).toBe(1) + await run.dispose() + }) + it('reports the message-turn outcome when a later non-message turn completes during flush', async () => { const { ctx, parent } = await setup([maxTokensResponse('partial answer')]) let injected = false @@ -186,6 +336,72 @@ describe('startInProcessRun', () => { expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + it('rejects an already-aborted resume before publication', async () => { + const { parent } = await setup([]) + const controller = new AbortController() + controller.abort('too late') + await expect(resumeInProcessRun({ + sessionId: SessionId('resumed-child'), + prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, + parent, + signal: controller.signal, + descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, + })).rejects.toThrow('aborted before child publication') + }) + + it('resumes without inventing undeclared agent model options', async () => { + const childId = SessionId('resumed-child') + let flushes = 0 + const child = { + id: childId, + options: {}, + session: new Session(childId), + status: 'idle', + acceptsNextStep: false, + ctx: { + sessions: { + flush: () => { + flushes++ + return Promise.resolve(true) + }, + }, + } as unknown as Context, + send(): void {}, + reserveTurnAdmission: () => undefined, + updateInbox: () => 'not-found', + followup(): void {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' as const }) } }, + inject(): void {}, + cancel(): void {}, + whenIdle: () => Promise.resolve(), + } as Agent + let resumedOptions: unknown + const parent = { + ctx: { + agents: { + resume: (options: { agentOptions: unknown }) => { + resumedOptions = options.agentOptions + return Promise.resolve({ agent: child, dispose: () => Promise.resolve() }) + }, + }, + }, + } as unknown as Agent + + const run = await resumeInProcessRun({ + sessionId: childId, + prompt: [{ type: 'text', text: 'continue' }], + source: { kind: 'plugin', plugin: 'test-coordinator' }, + parent, + signal: new AbortController().signal, + descriptor: { version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn' }, + }) + expect(resumedOptions).toEqual({}) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(flushes).toBe(1) + await run.dispose() + }) + it('uses the request signal after publication and dispose as cancellation paths', async () => { const { parent, adapter } = await setup(['hang', 'hang']) const controller = new AbortController() @@ -245,4 +461,104 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + + it('confirmed steering rejects a settled child instead of queueing an untracked turn', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const run = await startInProcessRun(request(parent), {}) + await run.result + await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) + .rejects.toThrow(/not running; the message was not delivered/) + const child = ctx.agents.get(run.id)! + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + + it('confirmed steering rejects when a concluding tool prevents request admission', async () => { + const { ctx, parent } = await setup([toolCallResponse('c1', 'finalize', {})]) + const enteredTool = Promise.withResolvers() + const releaseTool = Promise.withResolvers() + ctx.tools.register(defineContentToolFixture({ + name: 'finalize', + description: 'Finish the child run.', + parameters: {}, + async execute(_args, exec) { + enteredTool.resolve(undefined) + await releaseTool.promise + exec.concludeTurn() + return [{ type: 'text', text: 'final' }] + }, + })) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await enteredTool.promise + + const delivery = run.steer!([{ type: 'text', text: 'terminal race' }], { kind: 'user' }) + releaseTool.resolve(undefined) + await expect(delivery).rejects.toThrow(/stopped before steering admission; the message was not delivered/) + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + + it('confirmed steering fulfills only after the next request snapshot admits it', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const enteredStopping = Promise.withResolvers() + const releaseStopping = Promise.withResolvers() + let held = false + ctx.on('agent/turn-stopping', (agent) => { + if (agent.session.header.parentSession === undefined || held) return + held = true + enteredStopping.resolve(undefined) + return releaseStopping.promise + }) + + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await enteredStopping.promise + + let settled = false + const delivery = run.steer!([{ type: 'text', text: 'after the first step' }], { kind: 'user' }) + .then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + releaseStopping.resolve(undefined) + await delivery + + const result = await run.result + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('after the first step') + expect((result.output[0] as { text?: string }).text).toBe('second') + const steering = child.session.events.find(event => event.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.message.source).toEqual({ kind: 'user' }) + await run.dispose() + }) + + it('carries steering from a non-terminal flush window into a tracked next turn', async () => { + const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')]) + const enteredFlush = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + let held = false + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined || held) return + if (!session.events.some(event => event.type === 'turn/end')) return + held = true + enteredFlush.resolve(undefined) + return releaseFlush.promise + }) + + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + await enteredFlush.promise + expect(child.status).toBe('running') + + const delivery = run.steer!([{ type: 'text', text: 'next tracked turn' }], { kind: 'user' }) + releaseFlush.resolve(undefined) + await delivery + const result = await run.result + expect(adapter.requests).toHaveLength(2) + expect(child.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + expect((result.output[0] as { text?: string }).text).toBe('second') + await run.dispose() + }) }) diff --git a/packages/subagent/subagent-spawn/README.i18n.yaml b/packages/subagent/subagent-spawn/README.i18n.yaml index 970a148213..00eb8d457f 100644 --- a/packages/subagent/subagent-spawn/README.i18n.yaml +++ b/packages/subagent/subagent-spawn/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn/README.md -README.md: 868f829edbcfe2eb4d66ccd0ff9988924c70298b -README.zh.md: 99cfdf0e633345d1152c59cbe5ce7a029eb6ec9d +README.md: 811f19e6e68362bd14e75d0a9059ee61fda3f015 +README.zh.md: 2b189f77c4ff63ca026f472187a55c68def18ea1 diff --git a/packages/subagent/subagent-spawn/README.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/README.zh.md b/packages/subagent/subagent-spawn/README.zh.md index 99cfdf0e63..2b189f77c4 100644 --- a/packages/subagent/subagent-spawn/README.zh.md +++ b/packages/subagent/subagent-spawn/README.zh.md @@ -52,5 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: ## 已知限制与暂缓事项 -- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。 - **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index c006272cd5..0080c31521 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -8,8 +8,13 @@ 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, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, +} 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, @@ -40,12 +45,18 @@ class SpawnProvider implements SubagentProvider { constructor(readonly name: string) {} - start(request: SubagentStartRequest) { + start(request: SubagentProviderStartRequest) { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. return startInProcessRun(request, {}) } + + resume(request: SubagentProviderResumeRequest) { + // Cold resume reconstructs the persisted child from its own transcript + // under the live parent scope; the shared driver drives the follow-up turn. + return resumeInProcessRun(request) + } } 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..aeb6ef8cb7 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 confirmed steer (no run-level resume): a settled child rejects instead of queueing', async () => { const { ctx, parent } = await setup([textResponse('x')]) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) - 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 + // Confirmed live-only contract: after the child settles, delivery fails loud + // rather than falling back to Agent.steer()'s idle queue (which would + // start an untracked turn). + await expect(run.steer!([{ type: 'text', text: 'late' }], { kind: 'user' })) + .rejects.toThrow(/not running; the message was not delivered/) await run.dispose() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index e2e1b999ab..0d8b499482 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 3d5d5e7498b1700c07486cc6e894e72fed681bec -README.zh.md: eb26c79665d387a1e779050dad476d8672f67642 +README.md: a484352c486c067058bef806bad3bcd7623cf6cc +README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3d5d5e7498..a484352c48 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -10,26 +10,29 @@ 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, lifecycle events, and continuable-child orchestration. | +| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child, with cold resume. | +| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns, with cold resume. | +| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). | +| `@deepseek-ai/dsh-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 six main operations: | Member | Meaning | |---|---| | `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. | | `getProvider(name)` | Return the provider, or `undefined` when absent. | | `list()` | Return provider names in insertion order. | -| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. | +| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuation state cannot enter through this operation. | +| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. | +| `followup(parent, childId, content, { source, signal })` | Follow up with a durable child, matching `Agent.followup()` terminology. It steers the current activation or starts a new Task that cold-resumes the child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. | -`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, or set a child persona. Only the internal continuation manager can add a stable child id and durable descriptor to the provider-facing `SubagentProviderStartRequest`; cold provider resume is likewise private dispatch after descriptor lookup and parent authorization. Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries. @@ -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?` fulfills only after a request snapshot in the active child admits the message and rejects rather than queueing an untracked turn, while `SubagentProvider.resume?` reconstructs a persisted continuable child. A run represents one disposable activation, so it deliberately has no cold-resume operation — a disposed run cannot be reconstructed after restart. + +## The durable descriptor + +The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before any Task exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before provider dispatch; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. + ## Delegation depth 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; only the continuation manager dispatches it. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. +The service emits `subagent/start` only after an ordinary start or privately dispatched provider resume has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. @@ -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. One-shot background delegation registers a plain Task in the tool. Continuable background delegation calls `ctx.subagents.startContinuable()`, whose internal manager exists only while `ctx.tasks` and `ctx.agents` are available; session persistence is resolved per continuation operation. Collection and cancellation use the shared task tools. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. ## Model Experience -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/README.zh.md b/packages/subagent/subagent/README.zh.md index eb26c79665..9a750d5dfa 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -2,34 +2,37 @@ [English](README.md) | 中文 -subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程中、另一进程中,还是通过未来的传输机制运行。 +subagent seam 允许一个 agent(智能体)通过具名提供方把工作委派给子 agent。调用方使用统一的服务 API(`ctx.subagents`);提供方决定子 agent 在当前进程、另一进程还是未来的传输之上运行。 -## 包(package)的角色 +## 包角色 -该系列包把稳定接口与实现、面向模型的工具分开: +该能力族把稳定接口与实现、面向模型的工具分开: | 包 | 角色 | |---|---| -| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果类型和生命周期事件。 | -| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent。 | -| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent。 | -| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的工具。 | +| `@deepseek-ai/dsh-subagent` | 提供方注册表、请求/结果/描述符类型、生命周期事件和可继续子 agent 编排。 | +| `@deepseek-ai/dsh-subagent-spawn` | 支持从持久化存储恢复的全新进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容,并支持从持久化存储恢复的进程内子 agent。 | +| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 | +| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 | +| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 | 多个提供方可以使用不同名称共存。因此,部署可以同时公开低成本的进程内子 agent 和隔离的 ACP 子 agent,而无需改变服务契约。 ## 服务 API -`SubagentService` 有四个主要操作: +`SubagentService` 有六个主要操作: | 成员 | 含义 | |---|---| -| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会明确报错。 | +| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 | | `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 | | `list()` | 按插入顺序返回提供方名称。 | -| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理启动过程中取得的全部资源。 | +| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 | +| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 | +| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 | -`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消正在运行的子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。 +`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。 同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。 @@ -42,23 +45,27 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +运行时功能以可选方法表示,方法是否存在就是功能检查:`SubagentRun.steer?` 只有在活跃子 agent 的请求快照准入消息后才会兑现;无法准入时会拒绝,而不会把消息排入未受跟踪的轮次。`SubagentProvider.resume?` 则会重建持久化的可继续子 agent。run 表示一次可 dispose 的激活,因此有意不提供从持久化存储恢复操作;进程重启后无法重建已 dispose 的 run。 + +## 持久化描述符 + +该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在任何 Task 存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在提供方分发前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。 + ## 委派深度 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -运行时功能是 `SubagentRun` 上的可选方法:`sendMessage?` 可对正在运行的子 agent 进行 steering(中途引导),`resume?` 则异步创建延续运行。方法是否存在就是能力检查。 - `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP 不可以),不表示是否继承工具、服务或权限。 ## 所有权与生命周期 -`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使启动过程中已取得的资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。 +`provider.start(request): Promise` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。 -本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开该子 agent 本身,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 +本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。 -服务只会发出 `subagent/start`,而且是在 `start()` 兑现后。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 +服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。 运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。 @@ -66,17 +73,17 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再对运行执行 dispose(资源释放),然后才返回。后台委派不会改变该 seam;消费方把启动过程和最终运行注册到通用 `ctx.tasks` 运行时,随后使用共享任务工具进行收集和取消。完整契约见[后台 subagent 任务 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task。可继续后台委派会调用 `ctx.subagents.startContinuable()`;只有 `ctx.tasks` 和 `ctx.agents` 可用时,其内部管理器才会存在,而会话持久化按每项继续执行操作解析。收集和取消使用共享 Task 工具。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 ## 模型体验 -通过 `dsh-tool-subagent` 间接产生影响;它渲染提供方特定的 schema,以及前台或通用后台结果,同时子 agent 工作上下文只留在子 agent 中。 +通过 `dsh-tool-subagent` 和 `dsh-tool-subagent-control` 间接产生影响;它们渲染提供方特定的 schema,以及前台、后台或后续操作结果,同时子 agent 工作上下文只留在子 agent 中。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方负责请求前缀的任何变化。 +不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **运行时 steering 和延续只是 seam 能力**:当前工具中没有消费 `sendMessage` 和 `resume` 的面向模型消费方。 +- **ACP 子 agent 仍为一次性**:`AcpProvider.resume` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过提供方方法是否存在来确定。 - **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 58b51d1888..5f9f617eae 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -33,9 +33,19 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-tasks": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", @@ -43,6 +53,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts new file mode 100644 index 0000000000..1f4aa54a0c --- /dev/null +++ b/packages/subagent/subagent/src/continuation.ts @@ -0,0 +1,554 @@ +/** + * Internal continuable-subagent manager: stable child ids, descriptor + * persistence and lookup by known child id, Task-backed activation, and + * steer-or-resume message routing behind `ctx.subagents`. + * + * Every continuable activation — initial or resumed, parent- or human-started + * — has exactly one Task and one result. Task settlement awaits the child + * 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 + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' +import type { + SubagentProviderResumeRequest, + SubagentProviderStartRequest, + SubagentResult, + SubagentRun, + SubagentStartRequest, +} from './types.ts' +import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks' +import { SubagentError } from './error.ts' + +/** Attribution for a model coordinator's follow-up to one of its children. */ +export interface CoordinatorMessageSource { + readonly kind: 'coordinator' + /** Session id of the agent whose tool call produced the follow-up. */ + readonly senderSessionId: SessionId +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + coordinator: CoordinatorMessageSource + } +} + +/** 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 a continuable start. */ +export interface ContinuableStart { + /** The durable child session id, stable across activations. */ + readonly childId: SessionId + /** The initial activation's Task id. */ + readonly taskId: TaskId +} + +/** + * Options for following up with one continuable child. + */ +export interface SubagentFollowupOptions { + /** Durable attribution retained on either live or resumed delivery. */ + readonly source: MessageSource + /** Caller cancellation for a live-delivery admission wait. */ + readonly signal: AbortSignal +} + +/** + * How a continuable follow-up was routed: + * `steered` joined the running activation's existing Task without creating a + * Task of its own; `started` created a fresh Task that cold-resumes the + * durable child with the content. Failure is an exception, never a result — + * undelivered content throws. + */ +export type SubagentFollowupResult = + | { readonly route: 'steered'; readonly taskId: TaskId } + | { readonly route: 'started'; readonly taskId: TaskId } + +type StartProvider = (name: string, request: SubagentProviderStartRequest) => Promise +type ResumeProvider = (request: SubagentProviderResumeRequest) => Promise + +/** + * One child's current process-local activation: its Task and, after provider + * publication, its run. Installed before any provider or persistence await + * 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 + /** The activation-owned cancellation authority, created before any await. */ + readonly controller: AbortController + /** The producer's settlement (run disposed, outcome produced); assigned when the Task registers. */ + done: Promise | undefined + /** Resolved by the completion listener when the Task's terminal snapshot is recorded. */ + readonly terminal: PromiseWithResolvers +} + +/** + * Map a child result to the task outcome: completed carries final text, + * aborted is killed, and every other reason is failed without partial output. + * @param result - child terminal result. + * @returns outcome for the `ctx.tasks` registration. + */ +function runOutcome(result: SubagentResult): TaskOutcome { + switch (result.stopReason) { + case 'completed': + return { status: 'completed', output: finalText(result.output) } + case 'aborted': + return { status: 'killed' } + case 'error': + case 'max-tokens': + case 'refusal': + return { status: 'failed', detail: result.stopReason } + // Merge-extensible reasons remain failures with their raw detail. + default: + return { status: 'failed', detail: String(result.stopReason) } + } +} + +/** Render infrastructure failure detail without hiding a durability diagnosis. */ +function runFailureDetail(error: unknown): string { + return error instanceof HarnessError && error.code === 'DURABILITY_FAILED' + ? error.message + : String(error) +} + +/** + * Await the child result, dispose the run, then return its task outcome. Result + * and disposal failures become `failed`; when both fail, both details survive. + * @param run - live run to settle and release. + * @returns outcome after child resources are released. + */ +export async function settleRun(run: SubagentRun): Promise { + let outcome: TaskOutcome + try { + outcome = runOutcome(await run.result) + } catch (error: unknown) { + outcome = { status: 'failed', detail: runFailureDetail(error) } + } + try { + await run.dispose() + } catch (error: unknown) { + const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; ` + return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` } + } + return outcome +} + +/** Flatten a child's final output blocks to the task's final text. */ +function finalText(blocks: ContentBlock[]): string { + return blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') +} + +/** + * The continuable-subagent orchestration service. Tool schema and UI adapters + * are consumers of this one contract: parent and human messages route through + * {@link followup} and share one activation result and cancellation + * boundary, while foreground one-shot delegation keeps calling + * `ctx.subagents.start()` directly. + */ +export class SubagentContinuationManager { + /** Child session id → its current activation. Process-local, never durable. */ + private activations = new Map() + + constructor( + private readonly ctx: Context, + private readonly startProvider: StartProvider, + private readonly resumeProvider: ResumeProvider, + ) { + // Terminal publication is one of the two removal conditions. The exact + // Task id pins the resolution to this activation, never a later same-child one. + ctx.tasks.onTaskDone((snapshot) => { + for (const activation of this.activations.values()) { + if (activation.taskId === snapshot.id) activation.terminal.resolve() + } + }) + // TaskService deliberately keeps producer Tasks alive across a + // follow-up-tool or producer reload, so this manager's disposal must not + // strand the activations it can no longer route to: cancel each one and + // await producer settlement (run disposal) before releasing the map. The + // effect-scoped onTaskDone listener above is already gone by then, so + // terminal publication is resolved here instead of waiting forever. + ctx.effect(() => async () => { + const active = [...this.activations.values()] + this.activations.clear() + for (const activation of active) { + activation.controller.abort('subagent continuation manager disposed') + activation.terminal.resolve() + } + await Promise.allSettled(active.map((activation) => { + /* v8 ignore next 2 -- TaskService invokes `run` synchronously before `start` returns; + * every retained activation has `done`, while registration failure removes it. */ + if (activation.done === undefined) return Promise.resolve() + return activation.done + })) + }, 'subagents.continuations()') + } + + /** + * 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.startProvider(spec.provider, { + ...request, + signal, + continuation: { sessionId: childId, descriptor }, + })) + return { childId, taskId } + } + + /** + * Follow up with a known continuable child: steer its running + * activation, or cold-resume the durable session into a fresh Task-backed + * activation. The two routes are reported distinctly so timing-dependent + * routing is observable. Rejection means the message was NOT delivered — in + * particular, losing a race with Task settlement does not fall through to + * cold resume within the same call; a later retry after Task terminal may + * start the next activation. The started Task owns descriptor lookup and + * direct-parent authorization (its AbortSignal exists before that lookup), + * so an unknown, foreign, or descriptor-less child settles the started Task + * as `failed` with a detail reporting the id as unavailable. + * @param parent - the live parent agent sending the message (model tool or + * human adapter); Task access is authorized by its session id. + * @param childId - the stable child session id. + * @param content - the user-role content to deliver. + * @param options - caller attribution and cancellation. During live delivery, + * abort cancels the shared activation and rejects only after quiescence. + * @returns whether the content `steered` the existing Task or `started` a new one. + */ + async followup( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + this.assertOwnership(childId) + const activation = this.activations.get(childId) + if (activation !== undefined) { + return { + route: 'steered', + taskId: await this.steerActivation( + activation, + parent, + childId, + content, + options.source, + options.signal, + ), + } + } + return { + route: 'started', + taskId: this.resumeActivation(parent, childId, content, options.source), + } + } + + /** + * 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 SubagentError( + `subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`, + 'OWNERSHIP_CONFLICT', + ) + } + if (activation.run !== undefined && activation.run.localAgent !== live) { + throw new SubagentError( + `subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`, + 'OWNERSHIP_CONFLICT', + ) + } + } + + /** Deliver to the running activation's Task through confirmed live steering. */ + private async steerActivation( + activation: ActiveActivation, + parent: Agent, + childId: SessionId, + message: ContentBlock[], + source: MessageSource, + signal: AbortSignal, + ): Promise { + const taskId = activation.taskId + /* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */ + if (taskId === undefined) { + throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + // Owner-session authorization plus the live status for admission. + const snapshot = this.ctx.tasks.get(taskId, parent) + if (snapshot.status !== 'running') { + throw new SubagentError( + `subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered ` + + '— retry after it settles to start the next activation', + 'NOT_DELIVERED', + ) + } + const run = activation.run + if (run === undefined) { + throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED') + } + if (run.steer === undefined) { + throw new SubagentError( + `subagent "${childId}" provider does not accept live delivery; the message was not delivered`, + 'NOT_DELIVERED', + ) + } + const cancelActivation = (): void => { + activation.controller.abort(signal.reason) + } + signal.addEventListener('abort', cancelActivation, { once: true }) + if (signal.aborted) { + cancelActivation() + signal.removeEventListener('abort', cancelActivation) + return await this.cancelledLiveDelivery(activation, childId) + } + try { + await run.steer(message, source) + } catch (error: unknown) { + try { + signal.throwIfAborted() + } catch { + return await this.cancelledLiveDelivery(activation, childId, error) + } + // Confirmed steering lost the race with request admission. Deliberately no + // cold-resume fallback here: that would attach the message to a turn the + // caller did not observe. + throw new SubagentError( + `subagent "${childId}" stopped before delivery; the message was not delivered`, + 'NOT_DELIVERED', + { cause: error }, + ) + } finally { + signal.removeEventListener('abort', cancelActivation) + } + return taskId + } + + /** Reject a cancelled live delivery only after its shared activation is quiescent. */ + private async cancelledLiveDelivery( + activation: ActiveActivation, + childId: SessionId, + cause?: unknown, + ): Promise { + /* v8 ignore if -- a published run implies the producer assigned `done` before its provider await resolved. */ + if (activation.done === undefined) { + throw new Error('published subagent activation has no settlement promise') + } + await activation.done + throw new SubagentError( + `subagent "${childId}" live delivery was cancelled; the message was not delivered`, + 'CANCELLED', + cause === undefined ? undefined : { cause }, + ) + } + + /** + * Cold-resume a persisted child into a fresh Task-backed activation. The + * Task owns its `AbortController` before descriptor lookup: the load, + * 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[], + source: MessageSource, + ): TaskId { + const persistence = this.requirePersistence() + return this.startActivation(childId, resumeLabel(message), parent, async (signal) => { + let loaded: Awaited> + try { + loaded = await persistence.load(childId) + } catch (error: unknown) { + throw new SubagentError( + `subagent "${childId}" is unavailable`, + 'NOT_RESUMABLE', + { cause: error }, + ) + } + // The persistence seam takes no signal; recheck before any child work. + if (signal.aborted) throw new SubagentError('subagent resume was cancelled during lookup', 'CANCELLED') + // Authorize the persisted header before folding: only the direct parent + // recorded at creation may continue this child. + if (loaded.meta.parentSession !== parent.id) { + throw new SubagentError( + `subagent "${childId}" belongs to another parent session`, + 'UNAUTHORIZED', + ) + } + // Fold only the child's own suffix: a fork seed replays the parent's + // log, which may carry an ANCESTOR's descriptor when the parent is + // itself a continuable child. + const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0)) + if (descriptor === undefined) { + throw new SubagentError( + `subagent "${childId}" has no supported continuation state and cannot be resumed; ` + + 'do not retry send_message with this id', + 'NOT_RESUMABLE', + ) + } + return this.resumeProvider({ + sessionId: childId, + prompt: message, + source, + parent, + signal, + descriptor, + }) + }) + } + + /** + * 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, + controller: new AbortController(), + done: undefined, + terminal: Promise.withResolvers(), + } + this.activations.set(childId, activation) + let taskId: TaskId + try { + taskId = this.ctx.tasks.start({ + kind: 'subagent', + label, + owner, + run: (): TaskHooks => { + const done = (async (): Promise => { + try { + const run = await begin(activation.controller.signal) + activation.run = run + return await settleRun(run) + } catch (error: unknown) { + // A pre-publication abort rejects only after the provider's + // creation transaction rolled back to quiescence, so recording + // `killed` here honors the settlement-after-rollback contract. + return activation.controller.signal.aborted + ? { status: 'killed' } + : { status: 'failed', detail: String(error) } + } + })() + activation.done = done + void Promise.allSettled([done, activation.terminal.promise]).then(() => { + /* v8 ignore else -- service teardown clears the map while a producer is still settling. */ + if (this.activations.get(childId) === activation) this.activations.delete(childId) + }) + return { + cancel: (reason?: string) => { + // Cancellation targets the whole activation: every message that + // joined this turn shares the `killed` outcome. + activation.controller.abort(reason ?? 'subagent activation killed') + }, + done, + // No readOutput: the child session owns intermediate detail. + } + }, + }) + } 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 SubagentError( + '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 SubagentContinuationManager diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts new file mode 100644 index 0000000000..27b404fed9 --- /dev/null +++ b/packages/subagent/subagent/src/descriptor.ts @@ -0,0 +1,213 @@ +/** + * 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 continuation manager folds it back on resume. + * + * The descriptor deliberately snapshots explicit fields rather than the + * merge-extensible `AgentOptions` object: an unrelated extension value cannot + * 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 +} + +const DESCRIPTOR_KEYS = new Set([ + 'version', + 'provider', + 'agentProvider', + 'agentModel', + 'persona', + 'toolFilter', +]) +const TOOL_FILTER_KEYS = new Set(['allow', 'deny']) + +/** Whether a persisted JSON value is an object record. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Reject fields outside one versioned record's declared schema. */ +function assertKnownKeys(value: Record, keys: ReadonlySet, path: string): void { + const unknown = Object.keys(value).find(key => !keys.has(key)) + if (unknown !== undefined) { + throw new Error(`persisted subagent descriptor ${path} has unknown field "${unknown}"`) + } +} + +/** Read one optional string field from a persisted descriptor record. */ +function optionalString(value: Record, key: string): string | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (typeof field !== 'string') { + throw new Error(`persisted subagent descriptor ${key} must be a string`) + } + return field +} + +/** Read one optional string-array field from a persisted tool restriction. */ +function optionalStringArray(value: Record, key: string): string[] | undefined { + if (!Object.hasOwn(value, key)) return undefined + const field = value[key] + if (!Array.isArray(field)) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + const items: unknown[] = field + if (items.some(item => typeof item !== 'string')) { + throw new Error(`persisted subagent descriptor toolFilter.${key} must be an array of strings`) + } + return items as string[] +} + +/** Validate and reconstruct a persisted tool restriction. */ +function parseToolFilter(value: unknown): ToolRestriction { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor toolFilter must be an object') + } + assertKnownKeys(value, TOOL_FILTER_KEYS, 'toolFilter') + const allow = optionalStringArray(value, 'allow') + const deny = optionalStringArray(value, 'deny') + if (allow === undefined && deny === undefined) { + throw new Error('persisted subagent descriptor toolFilter must declare allow and/or deny') + } + return { + ...allow !== undefined ? { allow } : {}, + ...deny !== undefined ? { deny } : {}, + } +} + +/** Validate one persisted descriptor payload for the current runtime. */ +function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undefined { + if (!isRecord(value)) { + throw new Error('persisted subagent descriptor payload must be an object') + } + const version = value['version'] + if (typeof version !== 'number') { + throw new Error('persisted subagent descriptor version must be a number') + } + if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined + + assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload') + const provider = value['provider'] + if (typeof provider !== 'string') { + throw new Error('persisted subagent descriptor provider must be a string') + } + const agentProvider = optionalString(value, 'agentProvider') + const agentModel = optionalString(value, 'agentModel') + const persona = optionalString(value, 'persona') + const toolFilter = Object.hasOwn(value, 'toolFilter') + ? parseToolFilter(value['toolFilter']) + : undefined + return { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider, + ...agentProvider !== undefined ? { agentProvider } : {}, + ...agentModel !== undefined ? { agentModel } : {}, + ...persona !== undefined ? { persona } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + } +} + +/** + * Validate and detach descriptor inputs into the durable payload, before any + * Task or provider work begins — the same detached lossless-JSON boundary the + * 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). + * @throws when a current-version persisted payload does not match its complete + * declared schema. + */ +export function foldSubagentDescriptor(events: readonly SessionEvent[]): SubagentDescriptorData | undefined { + const event = events.find( + (candidate): candidate is SessionEvent<'subagent/descriptor'> => candidate.type === 'subagent/descriptor', + ) + if (event === undefined) return undefined + return parseSubagentDescriptor(event.data) +} diff --git a/packages/subagent/subagent/src/error.ts b/packages/subagent/subagent/src/error.ts new file mode 100644 index 0000000000..96409074f5 --- /dev/null +++ b/packages/subagent/subagent/src/error.ts @@ -0,0 +1,15 @@ +/** + * Typed failures shared by subagent service and provider operations. + * + * @module @deepseek-ai/dsh-subagent + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Typed failure for the subagent seam. */ +export class SubagentError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentError' + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 1267f276ab..023ac1fe26 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -13,12 +13,11 @@ * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. * - * Scope: the seam stays collection-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. + * Public operations express caller intent: `start` returns one ready owned run, + * `startContinuable` starts a Task-backed durable child, and `followup` routes + * later content without exposing whether the child is live. Provider resume + * dispatch stays private because only the continuation manager holds the + * resolved descriptor and authorization facts. * * Same-process providers are trusted typed collaborators. Requests, provider * descriptors, results, and lifecycle payloads are borrowed immutable values; @@ -33,30 +32,57 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, } from './types.ts' import { SubagentRunId } from './types.ts' +import { SubagentError } from './error.ts' +import SubagentContinuationManager from './continuation.ts' +import type { + ContinuableStart, + ContinuableStartSpec, + SubagentFollowupOptions, + SubagentFollowupResult, +} from './continuation.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, + SubagentContinuation, SubagentProvider, + SubagentProviderResumeRequest, + SubagentProviderStartRequest, SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason, SubagentStopReasonMap, } from './types.ts' +export { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, +} from './descriptor.ts' +export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts' +export { SubagentError } from './error.ts' +export { settleRun } from './continuation.ts' +export type { + ContinuableStart, + ContinuableStartSpec, + CoordinatorMessageSource, + SubagentFollowupOptions, + SubagentFollowupResult, +} from './continuation.ts' declare module '@deepseek-ai/dsh-agent' { interface AgentOptions { @@ -169,20 +195,56 @@ export interface SubagentRunEndInfo { readonly lastAssistantMessage?: ContentBlock[] } -/** Typed error for provider lookup, registration, and capability failures. */ -export class SubagentError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, code, options) - this.name = 'SubagentError' - } -} - -/** Named provider registry and capability-checked start surface. */ +/** Named provider registry with raw and Task-backed continuation operations. */ export class SubagentService extends Service { private providers = new Map() + private continuations: SubagentContinuationManager | undefined constructor(ctx: Context) { super(ctx, 'subagents') + ctx.inject(['tasks', 'agents'], (childCtx: Context) => { + const manager = new SubagentContinuationManager( + childCtx, + (name, request) => this.startProvider(name, request), + request => this.resumeProvider(request), + ) + this.continuations = manager + childCtx.effect(() => () => { + /* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */ + if (this.continuations === manager) this.continuations = undefined + }, 'subagents.continuationBinding()') + }) + } + + /** + * Start one durable continuable child through a Task-backed initial + * activation. + * @param spec - provider, Task label, and delegation request. + * @returns the stable child id and initial activation Task id. + */ + startContinuable(spec: ContinuableStartSpec): ContinuableStart { + return this.requireContinuations().startContinuable(spec) + } + + /** + * Follow up with a continuable child. A live child is steered and fulfillment + * confirms request admission; an idle child immediately returns a fresh Task + * whose descriptor lookup, authorization, and cold resume may later fail. + * @param parent - live direct parent authorizing the operation. + * @param childId - durable child session id. + * @param content - user-role content to deliver. + * @param options - durable attribution and caller cancellation; aborting a + * live-delivery wait cancels the shared activation and awaits quiescence. + * @returns the existing steered Task or newly started Task. + * @throws when continuation services are unavailable or live delivery is not admitted. + */ + followup( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + options: SubagentFollowupOptions, + ): Promise { + return this.requireContinuations().followup(parent, childId, content, options) } /** @@ -236,17 +298,64 @@ export class SubagentService extends Service { * @param request - child prompt, parent, signal, and optional capabilities. * @returns the ready holder-owned run. */ - async start(name: string, request: SubagentStartRequest): Promise { + async start(name: string, request: SubagentStartRequest & { readonly continuation?: never }): Promise { + return this.startProvider(name, request) + } + + /** Validate and dispatch one ordinary or service-resolved provider start. */ + private async startProvider( + name: string, + request: SubagentProviderStartRequest, + ): Promise { + const provider = this.expectProvider(name) + this.assertCapabilities(provider, request) + assertSubagentMaxDepth(request.maxDepth) + if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema) + if (request.continuation !== undefined && provider.resume === undefined) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support continuable children (no resume capability)`, + 'UNSUPPORTED_CAPABILITY', + ) + } + + return this.observeRun(name, request.parent, await provider.start(request)) + } + + /** Dispatch one authorized provider resume and observe its run lifecycle. */ + private async resumeProvider(request: SubagentProviderResumeRequest): Promise { + const name = request.descriptor.provider + 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) + /** Resolve the optional Task-backed continuation runtime or fail loud. */ + private requireContinuations(): SubagentContinuationManager { + if (this.continuations === undefined) { + throw new SubagentError( + 'continuable subagents require the tasks and agents services', + 'CONTINUATION_UNAVAILABLE', + ) + } + return this.continuations + } + + /** Emit the start/end lifecycle pair for one accepted run and return it. */ + private observeRun(name: string, parent: Agent, run: SubagentRun): SubagentRun { const runId = SubagentRunId(randomUUID()) const lifecycleIdentity = { runId, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 527d93c8e1..da75ae67cc 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,9 +6,10 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { SubagentDescriptorData } from './descriptor.ts' /** 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 — confirmed live steering + * is {@link SubagentRun.steer} and persisted cold resume is {@link SubagentProvider.resume}. Each + * flag corresponds one-to-one to a {@link SubagentStartRequest} option: `depthLimit` to + * `maxDepth`; the other names match. */ export interface SubagentCapabilities { readonly outputSchema: boolean @@ -41,8 +43,8 @@ export interface SubagentCapabilities { /** * What a caller asks for when starting a subagent. The tool layer builds this * from the model's `{ description, prompt }` plus its own config; the service - * validates {@link SubagentCapabilities} against the named provider, then - * passes it to {@link SubagentProvider.start}. + * validates {@link SubagentCapabilities} against the named provider and + * resolves a {@link SubagentProviderStartRequest} for dispatch. */ export interface SubagentStartRequest { /** Content delivered as the child's user message. */ @@ -93,6 +95,66 @@ export interface SubagentStartRequest { readonly persona?: string } +/** + * Provider-facing start request after the service resolves optional + * continuation state. Ordinary callers use {@link SubagentStartRequest}; only + * the Task-backed continuation path can attach a stable child identity and + * durable descriptor. + */ +export interface SubagentProviderStartRequest extends SubagentStartRequest { + /** + * Continuable-child state resolved by `ctx.subagents` before provider dispatch. + * The provider MUST publish exactly `sessionId` as the child identity + * instead of allocating one internally, and MUST append the snapshotted, + * model-hidden `subagent/descriptor` before the initial prompt is admitted. + * Requires {@link SubagentProvider.resume} (the + * continuation capability); the service rejects the request otherwise. + */ + readonly continuation?: SubagentContinuation | undefined +} + +/** + * The resolved continuable-child identity and durable composition record the + * service attaches before provider dispatch. + */ +export interface SubagentContinuation { + /** Service-allocated stable child session id, published verbatim. */ + readonly sessionId: SessionId + /** Snapshotted descriptor persisted in the child log for cold resume. */ + readonly descriptor: SubagentDescriptorData +} + +/** + * Provider-facing request for reconstructing a persisted continuable child. + * The continuation manager loads the child log, folds and authorizes its + * descriptor, then privately dispatches this resolved request to + * {@link SubagentProvider.resume}. The provider reconstructs the declared + * composition under the live parent's scope and drives one turn with `prompt`. + */ +export interface SubagentProviderResumeRequest { + /** The persisted child session id to resume. */ + readonly sessionId: SessionId + /** The follow-up message that starts the resumed activation's turn. */ + readonly prompt: ContentBlock[] + /** Attribution retained when the follow-up becomes the resumed turn's user-role message. */ + readonly source: MessageSource + /** + * The live parent agent — the direct parent recorded in the persisted child + * header. In-process backends reconstruct the child under this agent's + * currently loaded scope. + */ + 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 +} + /** * Why a subagent run ended. Merge-extensible (a backend may add variants); * consumers branch on the known cases and fall through `default`. The known @@ -155,8 +217,10 @@ export interface SubagentRun { * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport * failure resolves with `stopReason: 'error'` so the consumer maps it to an - * `isError` tool result. Rejects only on an infrastructure fault the seam - * cannot represent as a stop reason. + * `isError` tool result. For a continuable activation, a completed result + * also means the provider confirmed the activation's final state durable. + * Rejects on an infrastructure fault the seam cannot represent as a stop + * reason, including a failed required durability checkpoint. */ readonly result: Promise /** @@ -165,15 +229,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 (confirmed live-steering capability): submit additional content + * to the active child and fulfill only after a committed request snapshot + * admits it. Rejects when terminal policy, cancellation, disposal, or a lost + * settlement race prevents admission; it never falls through to a queued + * untracked turn or cold resume. A run represents one disposable activation, + * so resuming a settled child goes through {@link SubagentProvider.resume}. + * `source` is retained on the admitted steering message without changing its + * user role in model history. */ - sendMessage?(content: ContentBlock[]): void - /** - * OPTIONAL (resume capability): send a follow-up task to a settled child, - * continuing its session, and return a fresh run for the continuation. - */ - resume?(content: ContentBlock[]): Promise + steer?(content: ContentBlock[], source: MessageSource): Promise } /** @@ -200,5 +265,16 @@ export interface SubagentProvider { * fulfillment, the provider owns and cleans all partial resources before this * promise rejects. Ownership transfers to the caller only on fulfillment. */ - start(request: SubagentStartRequest): Promise + start(request: SubagentProviderStartRequest): Promise + /** + * OPTIONAL (continuation capability): reconstruct a persisted continuable + * child from its own transcript and declared descriptor, drive one + * follow-up turn, and return a fresh run. Method presence is the capability + * — the service rejects continuable starts and cold-resume dispatch on + * providers without it. Same publication contract as {@link start}: if + * reconstruction fails or `request.signal` aborts before fulfillment, the + * provider rolls its creation transaction back to quiescence before + * rejecting; after fulfillment the same signal cancels the published run. + */ + resume?(request: SubagentProviderResumeRequest): Promise } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts new file mode 100644 index 0000000000..24392d847e --- /dev/null +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -0,0 +1,938 @@ +import { afterEach, describe, expect, it, vi } 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 * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentService, { + settleRun, + SubagentError, + SUBAGENT_DESCRIPTOR_VERSION, +} 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 { + readonly requests: GenerateOptions[] = [] + + constructor(private script: GatedEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (!entry) throw new Error('GatedAdapter: script exhausted') + if (entry.gate) await entry.gate + 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, and subagents. */ +async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + let disposePersistence: (() => Promise) | undefined + if (options.persistence !== false) { + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) + roots.push(root) + const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) + disposePersistence = () => persistenceFiber.dispose() + } + 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, {}) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent, disposePersistence } +} + +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) +} + +async function waitPublishedRun(ctx: Context, childId: SessionId): Promise { + const continuations = ctx.subagents as unknown as { + continuations: { activations: Map } + } + await new Promise((resolve) => { + const timer = setInterval(() => { + if (continuations.continuations.activations.get(childId)?.run !== undefined) { + clearInterval(timer) + resolve() + } + }, 5) + }) +} + +function message(text: string) { + return [{ type: 'text' as const, text }] +} + +const coordinatorSource = { + kind: 'coordinator', + senderSessionId: SessionId('parent'), +} as const +const testSendSignal = new AbortController().signal + +function followup( + ctx: Context, + parent: Agent, + childId: SessionId, + content: ReturnType, + signal: AbortSignal = testSendSignal, +) { + return ctx.subagents.followup(parent, childId, content, { + source: { kind: 'user' }, + signal, + }) +} + +describe('SubagentService.startContinuable', () => { + it('returns both identities immediately; the Task settles with the child result after disposal', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + expect(started.childId).toMatch(/[0-9a-f-]{36}/) + expect(started.taskId).toBe('subagent-1') + + 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('fails a continuable Task before dispatch when its provider has no resume capability', async () => { + const { ctx, parent } = await setup([]) + const start = vi.fn(async () => { throw new Error('must not dispatch') }) + ctx.subagents.registerProvider({ + name: 'one-shot', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start, + }) + + const started = ctx.subagents.startContinuable(startSpec(parent, 'one-shot')) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('does not support continuable children') + expect(start).not.toHaveBeenCalled() + }) + + it('fails the Task when persistence detaches before the activation completes', async () => { + const releaseResponse = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise }, + ]) + const { ctx, parent, disposePersistence } = await setupWith(adapter) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + + await disposePersistence!() + releaseResponse.resolve(undefined) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('durability checkpoint failed') + expect(snapshot.detail).toContain('required durability checkpoint has no registered listener') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('publishes the service-allocated child id and appends the pre-turn descriptor', async () => { + const { ctx, parent } = await setup([textResponse('answer')]) + const seen: SessionEvent[] = [] + ctx.on('session/event', (session, event) => { + if (session.id !== SessionId('parent')) seen.push(event) + }) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const 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).toBeLessThan(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 service-allocated id. + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.id).toBe(started.childId) + expect(loaded.meta.parentSession).toBe(SessionId('parent')) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + }) + + it.each(['block', 'throw'] as const)( + 'persists the descriptor before initial prompt admission can $0', + async (outcome) => { + const { ctx, parent, adapter } = await setup([]) + ctx.on('agent/prompt-submit', async (subject, _message, _signal, next) => { + if (subject === parent) return next() + if (outcome === 'block') return { kind: 'block', reason: 'blocked by policy' } + throw new Error('prompt admission failed') + }) + + const started = ctx.subagents.startContinuable(startSpec(parent)) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(adapter.requests).toEqual([]) + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptorIndexes = loaded.events.flatMap((event, index) => + event.type === 'subagent/descriptor' ? [index] : []) + expect(descriptorIndexes).toHaveLength(1) + expect(loaded.events.some(event => event.type === 'turn/start')).toBe(false) + }, + ) + + it('rejects synchronously with no Task when persistence is not configured', async () => { + const { ctx, parent } = await setup([textResponse('unused')], { persistence: false }) + expect(() => ctx.subagents.startContinuable(startSpec(parent))) + .toThrow(/require session persistence/) + expect(ctx.tasks.list(parent)).toEqual([]) + }) + + it('rolls back the activation when Task preflight throws', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const realStart = ctx.tasks.start.bind(ctx.tasks) + ctx.tasks.start = () => { throw new Error('task preflight failed') } + try { + expect(() => ctx.subagents.startContinuable(startSpec(parent))) + .toThrow('task preflight failed') + } finally { + ctx.tasks.start = realStart + } + const continuations = ctx.subagents as unknown as { + continuations: { activations: Map } + } + expect(continuations.continuations.activations.size).toBe(0) + }) + + it('rejects a non-JSON descriptor input synchronously with no Task', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const spec = startSpec(parent) + expect(() => ctx.subagents.startContinuable({ + ...spec, + // A symbol survives the static ToolRestriction type only through this + // cast — exactly the durable-boundary input the snapshot rejects. + request: { ...spec.request, toolFilter: { deny: [Symbol('boom') as unknown as string] } }, + })).toThrow(/not losslessly JSON-serializable/) + expect(ctx.tasks.list(parent)).toEqual([]) + }) + + it('settles the Task as failed when provider startup fails after the ids were returned', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + const spec = { + provider: 'spawn', + label: 'broken delegation', + request: { + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + // The spawn provider enforces depth: parent depth 0 → child depth 1 > 0. + maxDepth: 0, + }, + } + const started = ctx.subagents.startContinuable(spec) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('maxDepth') + // The unmaterialized child id is reported unavailable on later use. + const followUp = await followup(ctx, parent, started.childId, message('hello?')) + expect(followUp.route).toBe('started') + const failed = await waitTerminal(ctx, followUp.taskId, parent) + expect(failed.status).toBe('failed') + expect(failed.detail).toContain('unavailable') + }) + + it('task_kill during the run aborts, disposes, and settles killed after quiescence', async () => { + const { ctx, parent } = await setup(['hang']) + const started = ctx.subagents.startContinuable(startSpec(parent)) + // Let the child publish and begin its turn. + await new Promise(resolve => setTimeout(resolve, 30)) + expect(ctx.agents.get(started.childId)).toBeDefined() + expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('task_kill during the final durability checkpoint settles killed', async () => { + const { ctx, parent } = await setup([textResponse('driver answer')]) + const checkpointStarted = Promise.withResolvers() + const releaseCheckpoint = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session.header.parentSession === undefined) return + flushes++ + if (flushes !== 2) return + checkpointStarted.resolve(undefined) + await releaseCheckpoint.promise + }) + const started = ctx.subagents.startContinuable(startSpec(parent)) + + await checkpointStarted.promise + expect(ctx.tasks.kill(started.taskId, parent, 'no longer needed')).toBe('requested') + releaseCheckpoint.resolve(undefined) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) +}) + +describe('SubagentService.followup', () => { + it('fails a cold-resume Task when the provider loses its resume capability', async () => { + const { ctx, parent } = await setup([textResponse('first answer')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const provider = ctx.subagents.getProvider('spawn')! + Object.defineProperty(provider, 'resume', { value: undefined, configurable: true }) + + const next = await followup(ctx, parent, started.childId, message('continue')) + const snapshot = await waitTerminal(ctx, next.taskId, parent) + + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('does not support resuming persisted children') + }) + + it('omits undeclared model selectors and rejects a provider without live delivery', async () => { + const { ctx } = await setup([]) + const result = Promise.withResolvers<{ + output: { type: 'text'; text: string }[] + stopReason: 'completed' + }>() + let descriptor: SessionEvent<'subagent/descriptor'>['data'] | undefined + ctx.subagents.registerProvider({ + name: 'no-steer', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async (request) => { + descriptor = request.continuation?.descriptor + return { + id: request.continuation!.sessionId, + localAgent: undefined, + result: result.promise, + async dispose() {}, + } + }, + resume: async () => { throw new Error('not used') }, + }) + const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}) + const started = ctx.subagents.startContinuable(startSpec(parent, 'no-steer')) + await waitPublishedRun(ctx, started.childId) + + expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' }) + await expect(followup(ctx, parent, started.childId, message('join'))) + .rejects.toThrow(/provider does not accept live delivery/) + + let terminalDeliveryError: unknown + let terminalDelivery: Promise | undefined + ctx.tasks.onTaskDone((snapshot) => { + if (snapshot.id !== started.taskId) return + terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then( + () => undefined, + (error: unknown) => { + terminalDeliveryError = error + }, + ) + }) + result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + await waitTerminal(ctx, started.taskId, parent) + await terminalDelivery + expect(String(terminalDeliveryError)).toContain('is completed') + }) + + it('rejects a registry agent different from the associated run agent', async () => { + const { ctx, parent } = await setup([]) + const result = Promise.withResolvers<{ + output: { type: 'text'; text: string }[] + stopReason: 'completed' + }>() + ctx.subagents.registerProvider({ + name: 'mismatched-local', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async (request) => { + const childId = request.continuation!.sessionId + const handle = await ctx.agents.create({ + sessionId: childId, + meta: { parentSession: request.parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + return { + id: childId, + localAgent: {} as Agent, + result: result.promise, + dispose: () => handle.dispose(), + } + }, + resume: async () => { throw new Error('not used') }, + }) + const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local')) + await waitPublishedRun(ctx, started.childId) + + await expect(followup(ctx, parent, started.childId, message('join'))) + .rejects.toThrow(/registry agent is not the associated activation's agent/) + result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + await waitTerminal(ctx, started.taskId, parent) + }) + + it('steers a running activation into the existing Task without creating a second Task', async () => { + // Hold the child's first model call open so the child is observably + // running when the message arrives; the steered content then drives a + // second step in the SAME turn. + let releaseFirst!: () => void + const gate = new Promise((resolve) => { releaseFirst = resolve }) + const adapter = new GatedAdapter([ + { chunks: textResponse('first step answer'), gate }, + { chunks: textResponse('steered turn answer') }, + ]) + const { ctx, parent } = await setupWith(adapter) + + const started = ctx.subagents.startContinuable(startSpec(parent)) + // Wait until the first immutable request has crossed the adapter boundary. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (adapter.requests.length === 1) { + clearInterval(timer) + resolve() + } + }, 5) + }) + + const delivery = ctx.subagents.followup( + parent, + started.childId, + message('also consider Y'), + { source: coordinatorSource, signal: testSendSignal }, + ) + releaseFirst() + const delivered = await delivery + expect(delivered).toEqual({ route: 'steered', taskId: started.taskId }) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('completed') + // Exactly one Task exists: steering created none. + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) + // The steered content joined the SAME child turn and drove another step. + const output = ctx.tasks.read(started.taskId, parent) + expect(output.text).toBe('steered turn answer') + const loaded = await ctx.sessionPersistence.load(started.childId) + const steering = loaded.events.find( + (event): event is SessionEvent<'steering/message'> => event.type === 'steering/message', + ) + expect(steering?.data.message.source).toEqual(coordinatorSource) + }) + + it('cancels the active Task without enqueueing when live delivery is already aborted', async () => { + const { ctx, parent, adapter } = await setup(['hang']) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const controller = new AbortController() + controller.abort('caller already cancelled') + + await expect(followup( + ctx, + parent, + started.childId, + message('must not enqueue'), + controller.signal, + )).rejects.toMatchObject({ code: 'CANCELLED' }) + expect(ctx.agents.get(started.childId)).toBeUndefined() + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + + it('rejects before acknowledgement when terminal policy prevents steering admission', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', 'structured_output', { answer: 7 }), + ]) + const startedTool = Promise.withResolvers() + const releaseTool = Promise.withResolvers() + ctx.on('tools/pre-execute', async (exec, next) => { + if (exec.name === 'structured_output') { + startedTool.resolve(undefined) + await releaseTool.promise + } + return next() + }) + + const base = startSpec(parent) + const started = ctx.subagents.startContinuable({ + ...base, + request: { + ...base.request, + outputSchema: { + type: 'object', + properties: { answer: { type: 'number' } }, + required: ['answer'], + }, + }, + }) + await startedTool.promise + + const delivery = ctx.subagents.followup( + parent, + started.childId, + message('follow-up that terminal policy rejects'), + { source: coordinatorSource, signal: testSendSignal }, + ) + releaseTool.resolve(undefined) + await expect(delivery).rejects.toThrow(/message was not delivered/) + + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('completed') + expect(adapter.requests).toHaveLength(1) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + + it('cold-resumes a settled child into a fresh Task and reports `started`', async () => { + const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + expect(ctx.agents.get(started.childId)).toBeUndefined() + + const followUp = await ctx.subagents.followup( + parent, + started.childId, + message('and then?'), + { source: coordinatorSource, signal: testSendSignal }, + ) + expect(followUp.route).toBe('started') + expect(followUp.taskId).not.toBe(started.taskId) + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('completed') + expect(ctx.tasks.read(followUp.taskId, parent).text).toBe('second answer') + // Fresh activation disposed again: durable child, no live Agent. + expect(ctx.agents.get(started.childId)).toBeUndefined() + + // The durable transcript accumulated BOTH activations' turns. + const loaded = await ctx.sessionPersistence.load(started.childId) + const userMessages = loaded.events.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') + expect(userMessages.map(event => (event.data.content[0] as { text: string }).text)) + .toEqual(['child task', 'and then?']) + expect(userMessages.map(event => event.data.source)) + .toEqual([{ kind: 'user' }, coordinatorSource]) + }) + + it('reconstructs the declared composition on cold resume', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const spec = { + provider: 'spawn', + label: 'scoped delegation', + request: { + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + persona: 'You are the resumable child.', + toolFilter: { deny: [] as string[] }, + }, + } + const started = ctx.subagents.startContinuable(spec) + await waitTerminal(ctx, started.taskId, parent) + + const loaded = await ctx.sessionPersistence.load(started.childId) + const descriptor = loaded.events.find((event): event is SessionEvent<'subagent/descriptor'> => event.type === 'subagent/descriptor') + expect(descriptor?.data.persona).toBe('You are the resumable child.') + expect(descriptor?.data.toolFilter).toEqual({ deny: [] }) + + const followUp = await followup(ctx, parent, started.childId, message('continue')) + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('completed') + // The resumed child's system prompt carried the persona back. + const resumed = await ctx.sessionPersistence.load(started.childId) + const headers = resumed.events.filter((event): event is SessionEvent<'request/header'> => event.type === 'request/header') + expect(headers.at(-1)?.data.header.system).toContain('You are the resumable child.') + }) + + it('fork children resume from their own transcript without re-forking parent history', async () => { + const { ctx, parent } = await setup([ + textResponse('parent turn one'), + textResponse('fork first answer'), + textResponse('parent turn two'), + textResponse('fork second answer'), + ]) + parent.followup(createUserMessage({ content: message('parent question one'), source: { kind: 'user' } })) + await parent.whenIdle() + + const started = ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitTerminal(ctx, started.taskId, parent) + const firstLoad = await ctx.sessionPersistence.load(started.childId) + const seedLength = firstLoad.meta.seedLength ?? 0 + expect(seedLength).toBeGreaterThan(0) + + // The parent gains NEW history the resume must not re-fork. + parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } })) + await parent.whenIdle() + + const followUp = await followup(ctx, parent, started.childId, message('follow up')) + await waitTerminal(ctx, followUp.taskId, parent) + const resumed = await ctx.sessionPersistence.load(started.childId) + // The persisted seed boundary is unchanged and parent turn two is absent. + expect(resumed.meta.seedLength).toBe(seedLength) + const texts = resumed.events + .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message') + .map(event => (event.data.content[0] as { text: string }).text) + expect(texts).toContain('parent question one') + expect(texts).not.toContain('parent question two') + }) + + it('a resumed child cannot regain a top-level delegation budget (header floor)', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = await followup(ctx, parent, started.childId, message('go on')) + + const childAgents: Agent[] = [] + const stop = ctx.on('agent/created', (agent: Agent) => { + if (agent.id === started.childId) childAgents.push(agent) + }) + await waitTerminal(ctx, followUp.taskId, parent) + stop() + // The resumed runtime options carry no depth, so the header keeps the floor. + const resumedChild = childAgents.at(-1) + expect(resumedChild).toBeDefined() + expect(resumedChild!.session.header.delegationDepth).toBe(1) + }) + + it('rejects a foreign child id: the started Task fails with UNAUTHORIZED and delivers nothing', async () => { + const { ctx, parent } = await setup([textResponse('other parent answer'), textResponse('unused')]) + const otherParent = ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) + const started = ctx.subagents.startContinuable(startSpec(otherParent)) + await waitTerminal(ctx, started.taskId, otherParent) + + const attempt = await followup(ctx, parent, started.childId, message('mine now')) + expect(attempt.route).toBe('started') + const snapshot = await waitTerminal(ctx, attempt.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain('another parent session') + }) + + it('rejects a persisted child with no descriptor as not resumable', async () => { + const { ctx, parent } = await setup([textResponse('plain child')]) + // A plain (non-continuable) child session persisted under this parent. + const handle = await ctx.agents.create({ + sessionId: SessionId('plain-child'), + meta: { parentSession: parent.id, delegationDepth: 1 }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + handle.agent.followup(createUserMessage({ content: message('do something'), source: { kind: 'user' } })) + await handle.agent.whenIdle() + await handle.dispose() + + const attempt = await followup(ctx, parent, SessionId('plain-child'), message('continue?')) + const snapshot = await waitTerminal(ctx, attempt.taskId, parent) + expect(snapshot.status).toBe('failed') + expect(snapshot.detail).toContain( + 'has no supported continuation state and cannot be resumed; do not retry send_message with this id', + ) + }) + + it('derives fallback and bounded labels for resumed activations', async () => { + const { ctx, parent } = await setup([]) + const blank = await followup(ctx, parent, SessionId('blank-child'), message(' ')) + const longText = 'x'.repeat(100) + const long = await followup(ctx, parent, SessionId('long-child'), message(longText)) + + expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up') + expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`) + await Promise.all([ + waitTerminal(ctx, blank.taskId, parent), + waitTerminal(ctx, long.taskId, parent), + ]) + }) + + it('rejects delivery to a live agent outside continuation ownership', async () => { + const { ctx, parent } = await setup([textResponse('unused')]) + // A live child created outside continuation orchestration. + const handle = await ctx.agents.create({ + sessionId: SessionId('rogue-child'), + meta: { parentSession: parent.id }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(SubagentError) + await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello'))) + .rejects.toThrow(/outside continuation ownership.*not delivered/) + await handle.dispose() + }) + + it('does not fall through to cold resume when steering loses the admission race', async () => { + // Deterministic race: hold run disposal open so the association still + // names a run whose child turn has already ended. + const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')]) + let releaseDispose!: () => void + const disposeGate = new Promise((resolve) => { releaseDispose = resolve }) + const provider = ctx.subagents.getProvider('spawn')! + const realStart = provider.start.bind(provider) + provider.start = async (request) => { + const run = await realStart(request) + const realDispose = run.dispose.bind(run) + return { + ...run, + ...run.steer !== undefined ? { steer: run.steer.bind(run) } : {}, + dispose: async () => { + await disposeGate + return realDispose() + }, + } + } + + const started = ctx.subagents.startContinuable(startSpec(parent)) + // Wait for the child to finish its turn while the run remains undisposed + // and the association therefore still holds. + await new Promise((resolve) => { + const timer = setInterval(() => { + const child = ctx.agents.get(started.childId) + if (child !== undefined && child.status === 'idle' + && child.session.events.some(event => event.type === 'turn/end')) { + clearInterval(timer) + resolve() + } + }, 5) + }) + + // Confirmed steering finds the settled child, fails loud, and does NOT start + // a cold resume within this call. + await expect(followup(ctx, parent, started.childId, message('too late?'))) + .rejects.toThrow(/not delivered/) + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId]) + releaseDispose() + await waitTerminal(ctx, started.taskId, parent) + // AFTER the Task settles, retry legitimately starts the next activation. + const retry = await followup(ctx, parent, started.childId, message('retry')) + expect(retry.route).toBe('started') + await waitTerminal(ctx, retry.taskId, parent) + }) + + it('each follow-up Task result is fenced to the parent session', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + const followUp = await followup(ctx, parent, started.childId, message('more')) + const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' }) + expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/) + }) + + it('kills a cold-resume activation during descriptor lookup without starting child work', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('never used')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + // Make the persistence load hang until the kill lands. + const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + let releaseLoad!: () => void + const gate = new Promise((resolve) => { releaseLoad = resolve }) + ctx.sessionPersistence.load = async (id) => { + await gate + return realLoad(id) + } + + const followUp = await followup(ctx, parent, started.childId, message('follow up')) + expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested') + releaseLoad() + const snapshot = await waitTerminal(ctx, followUp.taskId, parent) + expect(snapshot.status).toBe('killed') + // Cancellation during lookup prevented any child publication. + expect(ctx.agents.get(started.childId)).toBeUndefined() + }) + + it('admits one process-local activation per child: a second send during resume load steers or fails, never duplicates', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed answer')]) + const started = ctx.subagents.startContinuable(startSpec(parent)) + await waitTerminal(ctx, started.taskId, parent) + + const realLoad = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + let releaseLoad!: () => void + const gate = new Promise((resolve) => { releaseLoad = resolve }) + ctx.sessionPersistence.load = async (id) => { + await gate + return realLoad(id) + } + + const first = await followup(ctx, parent, started.childId, message('first follow-up')) + expect(first.route).toBe('started') + // The association is installed synchronously, so the competing caller + // observes the pending activation instead of starting a duplicate resume. + await expect(followup(ctx, parent, started.childId, message('second follow-up'))) + .rejects.toThrow(/not delivered/) + releaseLoad() + const snapshot = await waitTerminal(ctx, first.taskId, parent) + expect(snapshot.status).toBe('completed') + // Exactly one follow-up Task was created. + expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId, first.taskId]) + }) +}) + +describe('service disposal with live activations', () => { + it('cancels and settles a starting activation on service disposal instead of stranding it', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-hmr-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + const subagentsFiber = await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks, {}) + // A provider that stays pending until its signal aborts, so the activation + // is observably mid-start when the subagent service is disposed. + let sawAbort = false + ctx.subagents.registerProvider({ + name: 'pending', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: request => new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + sawAbort = true + reject(new Error('startup aborted')) + }, { once: true }) + }), + resume: () => Promise.reject(new Error('unreachable')), + }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + + const started = ctx.subagents.startContinuable({ + provider: 'pending', + label: 'will be interrupted', + request: { prompt: message('go'), parent }, + }) + // LocalTaskService keeps the producer Task; the disposing subagent service must + // cancel its activation and await settlement rather than strand it. + await subagentsFiber.dispose() + expect(sawAbort).toBe(true) + const snapshot = await waitTerminal(ctx, started.taskId, parent) + expect(snapshot.status).toBe('killed') + }) +}) + +describe('outcome mapping helpers', () => { + it.each([ + ['completed', { status: 'completed', output: 'partial' }], + ['aborted', { status: 'killed' }], + ['error', { status: 'failed', detail: 'error' }], + ['max-tokens', { status: 'failed', detail: 'max-tokens' }], + ['refusal', { status: 'failed', detail: 'refusal' }], + ['paused', { status: 'failed', detail: 'paused' }], + ] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => { + const output = [{ type: 'text' as const, text: 'partial' }] + await expect(settleRun({ + id: SessionId('child'), + localAgent: undefined, + result: Promise.resolve({ output, stopReason: stopReason as never }), + dispose: () => Promise.resolve(), + })).resolves.toEqual(expected) + }) + + it('settleRun disposes the run before reporting, on both result paths', async () => { + const order: string[] = [] + const completed = await settleRun({ + id: SessionId('child-1'), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), + dispose() { order.push('dispose'); return Promise.resolve() }, + }) + order.push('reported') + expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(order).toEqual(['dispose', 'reported']) + + // An infrastructure rejection still disposes and reports failed. + let disposed = false + const failed = await settleRun({ + id: SessionId('child-2'), + localAgent: undefined, + result: Promise.reject(new Error('transport gone')), + dispose() { disposed = true; return Promise.resolve() }, + }) + expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' }) + expect(disposed).toBe(true) + + const durabilityMessage = 'subagent "child-3" durability checkpoint failed; latest state unavailable: disk full' + const durabilityFailed = await settleRun({ + id: SessionId('child-3'), + localAgent: undefined, + result: Promise.reject(new HarnessError( + durabilityMessage, + 'DURABILITY_FAILED', + { cause: new Error('disk full') }, + )), + dispose: () => Promise.resolve(), + }) + expect(durabilityFailed).toEqual({ status: 'failed', detail: durabilityMessage }) + + const disposeFailed = await settleRun({ + id: SessionId('child-4'), + localAgent: undefined, + result: Promise.resolve({ output: [], stopReason: 'completed' }), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) + + const bothFailed = await settleRun({ + id: SessionId('child-5'), + localAgent: undefined, + result: Promise.reject(new Error('result failed')), + dispose: () => Promise.reject(new Error('reap failed')), + }) + expect(bothFailed).toEqual({ + status: 'failed', + detail: 'Error: result failed; dispose failed: Error: reap failed', + }) + }) +}) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 5b9eeaaa0f..e86de737ed 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,19 +1,23 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { + foldSubagentDescriptor, + snapshotSubagentDescriptor, + SUBAGENT_DESCRIPTOR_VERSION, SubagentError, assertSubagentMaxDepth, type SubagentCapabilities, type SubagentProvider, + type SubagentProviderStartRequest, type SubagentResult, type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent @@ -34,6 +38,7 @@ function baseRequest(overrides: Partial = {}): SubagentSta class StubProvider implements SubagentProvider { readonly inheritsParentContext = false startCount = 0 + lastRequest: SubagentProviderStartRequest | undefined constructor( readonly name: string, @@ -44,8 +49,9 @@ class StubProvider implements SubagentProvider { }, ) {} - async start(request: SubagentStartRequest): Promise { + async start(request: SubagentProviderStartRequest): Promise { this.startCount += 1 + this.lastRequest = request return { id: SessionId(`child:${this.name}:${request.parent.id}`), localAgent: undefined, @@ -99,6 +105,30 @@ describe('SubagentService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) + it('borrows ordinary start requests and exposes no provider continuation operations', async () => { + const { subagents } = await service() + const provider = new StubProvider('one-shot') + subagents.registerProvider(provider) + const request = baseRequest() + await subagents.start('one-shot', request) + + expect(provider.lastRequest).toBe(request) + expectTypeOf() + .not.toExtend[1]>() + expect('resume' in subagents).toBe(false) + }) + + it('rejects Task-backed continuation operations when their runtime services are absent', async () => { + const { subagents } = await service() + expect(() => { + subagents.startContinuable({ + provider: 'unused', + label: 'work', + request: baseRequest(), + }) + }).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' })) + }) + it.each([ ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], @@ -247,3 +277,70 @@ describe('SubagentService', () => { expect(error.code).toBe('NO_PROVIDER') }) }) + +describe('subagent descriptors', () => { + const event = (data: unknown): SessionEvent<'subagent/descriptor'> => ({ + type: 'subagent/descriptor', + data, + } as unknown as SessionEvent<'subagent/descriptor'>) + + it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => { + expect(foldSubagentDescriptor([])).toBeUndefined() + const minimal = snapshotSubagentDescriptor({ provider: 'spawn' }) + expect(minimal).toEqual({ + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + }) + expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal) + const complete = { + version: SUBAGENT_DESCRIPTOR_VERSION, + provider: 'spawn', + agentProvider: 'deepseek', + agentModel: 'chat', + persona: 'reviewer', + toolFilter: { allow: ['read'], deny: ['bash'] }, + } + expect(snapshotSubagentDescriptor({ + provider: complete.provider, + agentProvider: complete.agentProvider, + agentModel: complete.agentModel, + persona: complete.persona, + toolFilter: complete.toolFilter, + })).toEqual(complete) + expect(foldSubagentDescriptor([event(complete)])).toEqual(complete) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { allow: ['read'] } }), + ])).toMatchObject({ toolFilter: { allow: ['read'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', toolFilter: { deny: ['bash'] } }), + ])).toMatchObject({ toolFilter: { deny: ['bash'] } }) + expect(foldSubagentDescriptor([ + event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }), + ])).toBeUndefined() + expect(() => snapshotSubagentDescriptor({ + provider: 'spawn', + toolFilter: { deny: [Symbol('not-json')] as unknown as string[] }, + })).toThrow('not losslessly JSON-serializable') + }) + + it.each([ + ['string payload', 'invalid', 'payload must be an object'], + ['null payload', null, 'payload must be an object'], + ['array payload', [], 'payload must be an object'], + ['missing version', { provider: 'spawn' }, 'version must be a number'], + ['string version', { version: '1', provider: 'spawn' }, 'version must be a number'], + ['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'], + ['missing provider', { version: 1 }, 'provider must be a string'], + ['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'], + ['invalid agent provider', { version: 1, provider: 'spawn', agentProvider: 7 }, 'agentProvider must be a string'], + ['invalid agent model', { version: 1, provider: 'spawn', agentModel: [] }, 'agentModel must be a string'], + ['invalid persona', { version: 1, provider: 'spawn', persona: {} }, 'persona must be a string'], + ['non-object tool filter', { version: 1, provider: 'spawn', toolFilter: [] }, 'toolFilter must be an object'], + ['unknown tool-filter field', { version: 1, provider: 'spawn', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'], + ['empty tool filter', { version: 1, provider: 'spawn', toolFilter: {} }, 'toolFilter must declare allow and/or deny'], + ['non-array allow list', { version: 1, provider: 'spawn', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'], + ['non-string deny item', { version: 1, provider: 'spawn', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'], + ])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => { + expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail) + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 713e214f04..6684758659 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../core/scope" }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../tasks/tasks" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml new file mode 100644 index 0000000000..fc7ab47339 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md +README.md: 44fbd44b035ce283e404c491d9fa143a08b71127 +README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md new file mode 100644 index 0000000000..44fbd44b03 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-tool-subagent-control + +English | [中文](README.zh.md) + +The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work. + +The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered. + +## 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 started a cold-resume activation. Synchronous routing failures — an ownership conflict, a lost steering race, no live-delivery capability — are errored results whose message states the message was not delivered. An absent activation always reports `started`: lookup runs inside that Task, so an unknown, foreign, or descriptor-less child surfaces as the started Task settling `failed` (read through `task_output`), not as an errored `send_message` result. + +#### Token effect + +One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` (the completion notice is a status line, never the response). + +#### 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/README.zh.md b/packages/subagent/tool-subagent-control/README.zh.md new file mode 100644 index 0000000000..3fa1d1e543 --- /dev/null +++ b/packages/subagent/tool-subagent-control/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-tool-subagent-control + +[English](README.md) | 中文 + +可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。 + +本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。 + +## 模型体验 + +### 工具 schema + +#### 模型看到的内容 + +已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id` 和 `message`,说明投递或继续执行的语义,以及通过 `task_output` 收集结果的路径。 + +#### Token 影响 + +每个父级请求支付固定的 schema 成本。 + +#### KV Cache 影响 + +前缀保持稳定;schema 不会在运行时改变。 + +### 投递结果 + +#### 模型看到的内容 + +消息加入运行中的激活时返回 `message delivered to running task `;消息启动一次从持久化存储恢复的激活时返回 `message started task continuing subagent `。同步路由失败,包括所有权冲突、steering(中途引导)竞态失败和缺少在线投递功能,都会成为出错的结果,其消息说明该消息未送达。不存在激活时始终报告 `started`:查找在该 Task 内运行,因此未知、属于其他 parent 或缺少描述符的子 agent 会表现为已启动的 Task 结算为 `failed`(通过 `task_output` 读取),而不是出错的 `send_message` 结果。 + +#### Token 影响 + +每次调用产生一条简短确认消息;子 agent 的响应只会在通过 `task_output` 收集时进入父级历史(完成通知是状态行,绝不是响应)。 + +#### KV Cache 影响 + +仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与延期工作 + +- **已投递的消息没有独立结果**:其效果体现在当前 Task 的最终结果中;只有已启动的后续操作才拥有新的 Task 结果。 +- **投递可能在时序竞态中失败**:消息与 Task 结算、取消或清理发生竞态时会明确失败,不会改用从持久化存储恢复;模型会在 Task 结算后重试。 diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json new file mode 100644 index 0000000000..4fdd483ff7 --- /dev/null +++ b/packages/subagent/tool-subagent-control/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-tool-subagent-control", + "description": "Globally named send_message tool over ctx.subagents continuations", + "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": "^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-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..af85457262 --- /dev/null +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -0,0 +1,82 @@ +/** + * The globally named `send_message` tool: a thin model-facing adapter over + * `ctx.subagents.followup()`. It performs no lifecycle routing of its + * own — steer-or-resume orchestration belongs to the subagent service — and it + * lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent` + * instances so multiple delegation tools share one control tool. + * @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' + +export const name = 'tool-subagent-control' +export const inject = ['tools', 'subagents'] + +/** + * Register the `send_message` tool. + * @param ctx - context carrying the tool registry and subagent 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}`, + }], + }, + async execute(args, exec) { + const parent = exec.agent + if (!parent) { + // Non-agent callers have no session to authorize Task access with. + throw new Error('send_message requires a calling agent (exec.agent was undefined)') + } + const message: ContentBlock[] = [{ type: 'text', text: args.message }] + const result = await ctx.subagents.followup( + parent, + SessionId(args.subagent_id), + message, + { + source: { kind: 'coordinator', senderSessionId: parent.id }, + signal: exec.signal, + }, + ) + return 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..c993426a26 --- /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 subagent 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..b035fa1127 --- /dev/null +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, vi } 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 * 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(tool) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent, adapter } +} + +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, + signal: AbortSignal = testToolSignal, +) { + return ctx.tools.execute({ + signal, + 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.subagents.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]') + const loaded = await ctx.sessionPersistence.load(started.childId) + const followUp = loaded.events.findLast(event => + event.type === 'user/message', + ) + expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ + kind: 'coordinator', + senderSessionId: parent.id, + }) + }) + + it('renders the steered route when the child is still running', async () => { + // Script the child's single turn as two steps: the steer joins mid-turn. + const { ctx, parent } = await setup([]) + let steered: string | undefined + let source: unknown + // Reach past the tool into the subagent service to fake a running route + // deterministically: the tool is a thin adapter, so its steered wording is + // what this test pins. + ctx.subagents.followup = async (agent, _childId, message, options) => { + steered = (message[0] as { text: string }).text + source = options.source + return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) } + } + 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(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id }) + expect(text(result)).toBe('message delivered to running task subagent-9') + }) + + it('cancels a pending live-delivery wait when the tool signal aborts', async () => { + const { ctx, parent, adapter } = await setup(['hang']) + const started = ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'hung work', + request: { prompt: [{ type: 'text', text: 'wait' }], parent }, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const deliveryStarted: PromiseWithResolvers = Promise.withResolvers() + const followup = ctx.subagents.followup.bind(ctx.subagents) + ctx.subagents.followup = (agent, childId, message, options) => { + const delivery = followup(agent, childId, message, options) + deliveryStarted.resolve() + return delivery + } + + const controller = new AbortController() + const execution = callTool(ctx, 'send_message', { + subagent_id: started.childId, + message: 'follow up', + }, parent, controller.signal) + await deliveryStarted.promise + controller.abort('parent tool cancelled') + + const result = await execution + expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe('CANCELLED') + expect(ctx.agents.get(started.childId)).toBeUndefined() + const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent) + expect(snapshot.status).toBe('killed') + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false) + }) + + it('reports a delivery failure as an errored, not-delivered result', async () => { + const { ctx, parent } = await setup([]) + const result = await callTool(ctx, 'send_message', { + 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) + 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', 'subagents']) + expect(typeof tool.apply).toBe('function') + }) +}) diff --git a/packages/subagent/tool-subagent-control/tsconfig.json b/packages/subagent/tool-subagent-control/tsconfig.json new file mode 100644 index 0000000000..3a57a0437e --- /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" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 9c8e235669..46daae94ea 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 20bb6b9c59a13f23301368faefe18849ccc0b1e9 -README.zh.md: 8da57896359f5ac47d0ec076c3395d2e7fb1e02a +README.md: 9d60363602a9825730984700a7fe987d911e1cac +README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 20bb6b9c59..9d60363602 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. -With `run_in_background: true`, the 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`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent as task `. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -21,6 +21,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | +| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. | | `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | @@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. +Start returns exactly `started subagent as task ` in configured continuable mode, or `started background subagent task ` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8da5789635..5964c38bd8 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -10,7 +10,7 @@ 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。 -设置 `run_in_background: true` 后,工具会在启动提供方前注册父级拥有的任务,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `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` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task `。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent as task `。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -21,6 +21,7 @@ | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | +| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 | | `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | @@ -64,7 +65,7 @@ #### 模型看到的内容 -启动时原样返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知。 +在已配置的 continuable 模式下,启动时精确返回 `started subagent as task `;在已配置的 one-shot 模式下,则返回 `started background subagent task `。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index b5c7b5d94f..353f67ff24 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -43,7 +43,11 @@ "@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-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..6033bea875 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,20 +1,21 @@ /** * 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. + * Background policy is selected by this plugin's configuration: one-shot + * calls own a plain Task, while continuable calls use + * `ctx.subagents.startContinuable()`. * @module @deepseek-ai/dsh-tool-subagent */ 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 { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' export const name = 'tool-subagent' @@ -34,6 +35,12 @@ export interface Config { * parameter and reject forced background calls. */ enableRunInBackground?: boolean + /** + * Background execution policy (default `one-shot`). `continuable` requires + * a provider with persisted resume support and returns both child and Task + * ids; follow-up adapters remain independently optional. + */ + backgroundMode?: 'one-shot' | 'continuable' /** * Agent options applied to every child; omitted fields use child-loop defaults. */ @@ -70,6 +77,7 @@ export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), enableRunInBackground: z.boolean().default(true), + backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), @@ -85,18 +93,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 +103,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 +134,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 +173,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 +196,20 @@ export function apply(ctx: Context, config: Config): void { } const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false + const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' + if (continuable && provider.resume === undefined) { + throw new Error( + `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, + ) + } disposeTool = ctx.tools.register(defineTool({ name: config.toolName ?? 'subagent', description: wording.description + (backgroundEnabled - ? ' 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' + + ' stable subagent id and current task id; collect the result with `task_output` and stop it with' + + ' `task_kill`.' + : ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.' : ''), parameters: { description: { @@ -276,7 +225,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 or stop with task_kill.' + : 'Run as a background task and return its id; collect with task_output or stop with task_kill.', }, } : {}, }, @@ -289,6 +241,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 +258,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 +271,47 @@ 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 started = ctx.subagents.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..1b45640a94 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,17 @@ 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 * 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 @@ -61,6 +68,21 @@ function text(result: { content: { type: string; text?: string }[] }): string { } describe('dsh-tool-subagent', () => { + it('rejects continuable background policy when the configured provider cannot resume', async () => { + let failure: unknown + try { + await setup({ + provider: 'mock', + backgroundMode: 'continuable', + }) + } catch (error: unknown) { + failure = error + } + expect(String(failure)).toContain( + 'provider "mock" does not support `backgroundMode: continuable`', + ) + }) + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) @@ -646,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => { return ctx } + it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }) + const parent = ownerAgent(ctx, 'sess-parent') + let resumeCalls = 0 + ctx.subagents.registerProvider({ + name: 'resumable', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async request => ({ + id: SessionId('one-shot-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'one-shot answer' }], + stopReason: request.signal.aborted ? 'aborted' : 'completed', + }), + dispose: () => Promise.resolve(), + }), + resume: async () => { + resumeCalls += 1 + throw new Error('one-shot policy must not resume') + }, + }) + tool.apply(ctx, { + provider: 'resumable', + toolName: 'subagent_resumable', + backgroundMode: 'one-shot', + maxDepth: 'provider-managed', + }) + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('resumable-one-shot'), + name: 'subagent_resumable', + arguments: { description: 'work', prompt: 'go', run_in_background: true }, + agent: parent, + }) + + expect(text(started)).toBe('started background subagent task subagent-1') + expect(resumeCalls).toBe(0) + }) + it('returns a task id immediately and the answer is collected through task_output', async () => { const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' }) const parent = ownerAgent(ctx, 'sess-parent') @@ -808,59 +871,56 @@ 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 without any model-facing follow-up adapter. */ + async function continuableSetup() { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) + 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(tool, { provider: 'spawn', backgroundMode: 'continuable' }) + 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('starts a continuable child and returns both ids without send_message', async () => { + const { ctx, parent } = await continuableSetup() + const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! + expect(schema.description).not.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 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', - }) + 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) }) + }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 7a803f2c80..5124147cd8 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -8,7 +8,8 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l const configPath = '/tmp/fixture.cordis.yml' const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url)) -const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '') +// macOS realpaths temp dirs into /private; TMPDIR may live under /var or /tmp. +const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/(?:var|tmp)\/)/, '') describe('runLoaderSmoke', () => { it('isolates the process, closes stdin, captures output, and removes the cwd', async () => { diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 765bdebf86..fb8ac03c23 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { acceptsNextStep: false, ctx: scopeFiber.ctx, followup: () => {}, - steer: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: (): 'not-found' => 'not-found', diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 709fb4f181..a62b695b72 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -228,7 +228,7 @@ export async function createTuiTestHarness { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -5553,7 +5553,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -5588,14 +5588,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -5626,7 +5626,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -5670,7 +5670,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a8b7f4a8..45790d4591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,6 +441,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 @@ -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 @@ -4873,6 +4879,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5115,9 +5127,21 @@ 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-spawn': + specifier: workspace:^ + version: link:../subagent-spawn '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5137,6 +5161,54 @@ 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-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': @@ -6447,6 +6519,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..a4d555055d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -86,6 +86,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..5c88ab3fc2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1775, "docs/AGENTS.md": 1150, - "docs/architecture.md": 1920, + "docs/architecture.md": 2160, "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..f7ab385afd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -160,7 +160,14 @@ export const LINK_MAP: Readonly> = { SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', + ContinuableStart: 'subagent.md', + ContinuableStartSpec: 'subagent.md', + CoordinatorMessageSource: 'subagent.md', + SubagentFollowupOptions: 'subagent.md', + SubagentFollowupResult: 'subagent.md', SubagentProvider: 'subagent.md', + SubagentProviderResumeRequest: 'subagent.md', + SubagentProviderStartRequest: 'subagent.md', SubagentRun: 'subagent.md', SubagentService: 'subagent.md', SubagentStartRequest: 'subagent.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 77c0302dce..ed46fd687d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -424,11 +424,11 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'subagents', pkg: 'subagent', - title: 'Subagent provider registry', + title: 'Subagent provider and continuation service', mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], - consumers: ['tool-subagent', 'tool-ralph'], - note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', + consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], + note: 'Providers implement transports; the service also owns optional Task-backed continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a77bdb6d95..96d6a97457 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -28,6 +28,7 @@ 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 * 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 +107,7 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), + resume: () => Promise.reject(new Error('tool-catalog provider cannot resume a child')), } ctx.subagents.registerProvider(provider) } @@ -379,6 +381,21 @@ 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.subagents'], + writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'], + async mount(ctx) { + await ctx.plugin(SubagentService) + await ctx.plugin(LocalTaskService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(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..0a17861a07 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1094,6 +1094,36 @@ "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProviderStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentContinuation", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "CoordinatorMessageSource", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentFollowupOptions", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentFollowupResult", + "source": "packages/subagent/subagent/src/continuation.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProviderResumeRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", diff --git a/tsconfig.host.json b/tsconfig.host.json index ef72d6a43d..2e5c6ea08c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -179,6 +179,7 @@ { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, { "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" },