diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6780e9115e..5eb4b70f40 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -154,7 +154,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:27`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -165,7 +165,7 @@ Requires: `sandbox` * Plugin config: the local executor's knobs plus the sandbox policy. All * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the * fail-safe default; an example that wants a workspace-writable agent opts in - * explicitly). The runner choice is NOT configured here: which platform + * explicitly). The runner choice is not configured here: which platform * backend confines the command is the `ctx.sandbox` provider's config. */ export interface Config extends LocalConfig { @@ -886,19 +886,14 @@ Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/ti Requires: `tools` · `bash` · `systemPrompt` ```ts config-catalog -/** Config: whether the model may background commands (the producer-opt-in flag). */ +/** Configures whether the model may background commands. */ export interface Config { - /** - * Expose `run_in_background` in the bash schema (default true). Disabled, - * the parameter is absent entirely — schema and capability never disagree. - * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing - * one fails the call loud with the load-these-packages message. - */ + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean } ``` -Source: [`packages/bash/tool-bash/src/index.ts:48`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:30`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -962,42 +957,29 @@ export interface Config { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string /** - * The model-facing tool name to register (default `subagent`). To expose more - * than one transport, load this plugin once per provider — each load MUST set - * a distinct `toolName` (the tool registry rejects a duplicate name), e.g. - * `{ provider: 'spawn', toolName: 'subagent' }` and - * `{ provider: 'acp', toolName: 'subagent_acp' }`. + * Model-facing tool name (default `subagent`). Each loaded instance must use + * a distinct name. */ toolName?: string /** - * Expose `run_in_background` in this instance's schema (default true). - * Disabled, the parameter is absent entirely — schema and capability never - * disagree; delegation through this instance stays strictly synchronous. - * Backgrounding also needs the `ctx.tasks` runtime at call time; a missing - * one fails the call loud with the load-these-packages message. + * Expose `run_in_background` (default true). Disabled instances omit the + * parameter and reject forced background calls. */ enableRunInBackground?: boolean /** - * Default per-child agent options (model) applied to every spawned child. - * Omitted fields fall back to the child loop's own defaults. + * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions /** - * Per-child persona applied to every child this tool spawns: a scoped - * `deployment:persona` section shadowing the deployment's persona for the - * child alone. Requires the bound provider's `persona` capability - * (in-process backends support it; a request against one that doesn't is - * rejected at start). Omitted ⇒ the child renders the deployment persona. + * Per-child persona that shadows `deployment:persona`. Requires the + * provider's `persona` capability; omission preserves the deployment persona. */ persona?: string /** - * Tool scoping applied to every child this tool spawns (see - * `SubagentStartRequest.toolFilter`): the named global tools vanish from - * the child's prompt AND refuse to execute. Requires the provider's - * `toolFilter` capability. Unknown names fail the spawn loudly. Note the - * child otherwise sees every global tool — including this delegation tool - * itself; `deny`-listing it (or setting `maxDepth`) is how a deployment - * bounds recursion. + * Tool filter applied to every child. Filtered tools disappear from its + * prompt and reject execution. Requires the provider's `toolFilter` + * capability; unknown names fail startup. Children otherwise see this tool, + * so deny it or set `maxDepth` to bound recursion. */ toolFilter?: { /** Global tool names the child keeps; everything else is removed. */ @@ -1006,12 +988,8 @@ export interface Config { deny?: string[] } /** - * Recursion cap applied to every child this tool spawns (see - * `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper - * than this in the delegation tree is rejected. Requires the provider's - * `depthLimit` capability. Must be a non-negative safe integer and is - * validated when the plugin loads. Omitted ⇒ unbounded (bound it in - * deployments that expose this tool to children). + * Maximum child depth. Requires the provider's `depthLimit` capability and a + * non-negative safe integer. Omission is unbounded. */ maxDepth?: number } @@ -1019,14 +997,14 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:59`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:23`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-tasks` Requires: `tools` · `tasks` · `systemPrompt` ```ts config-catalog -/** Config: the `task_output` wait bounds (defaulted, capped — never hardcoded). */ +/** Configures bounded `task_output` waits. */ export interface Config { /** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */ waitTimeoutMs?: number @@ -1035,7 +1013,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:34`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:21`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 33314f7503..6ec964c335 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.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 -adding-a-tool.md: c98b6901e1a58db1872a0abf12930a9b4bcce9c1 -adding-a-tool.zh.md: 69cb9fe2b63296e21409d9c4ea375a51c95a2648 +adding-a-tool.md: da214702939e01fedf3d0d69be7560bbafe0696a +adding-a-tool.zh.md: b216d18b1593cd7e6074685bd39684f1b9694eac diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index c98b6901e1..da21470293 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -45,7 +45,9 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Long-running work -Hand long-running work to the shared task runtime instead of inventing a task protocol: gate a `run_in_background` parameter behind your plugin's own defaulted `enableRunInBackground`-style config, then call `ctx.tasks.start({ kind, label, owner: exec.agent, run: () => ({ cancel, done, readOutput? }) })` (`@deepseek-ai/dsh-tasks`) — the runtime preflights everything that can fail (the control-surface fence, validation, owner-cleanup attach) BEFORE invoking your `run()` starter, so work that started without a collectable id is structurally impossible (no try/catch rollback in your tool). The runtime issues the `-N` id, fences access to the owning session, cancels-and-awaits your task when the owner disposes, and the generic `task_output`/`task_list`/`task_kill` tools plus the completion notice come from `@deepseek-ai/dsh-tool-tasks` — your tool returns `started background task ` and is done. Your producer keeps its execution concerns: `done` must settle at quiescence (resources released), and a stream-kind `readOutput` owns its own truncation/spill formatting (bound buffers, spill full output to disk so nothing is silently lost — see tool-bash's `renderProcessRead`). Do NOT wire `exec.signal` to the background work after the id is returned; check `exec.signal?.aborted` once before calling `start`, then leave cancellation to `task_kill` and owner cleanup. +Gate `run_in_background` with producer config, reject a pre-aborted call, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. + +The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## Execution policy and observation diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 69cb9fe2b6..b216d18b15 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -45,7 +45,9 @@ export function apply(ctx: Context) { ## 长时间运行的工作 -不要为长时间运行的工作另造任务协议,而应将其交给共享 task 运行时:通过插件自身带默认值的 `enableRunInBackground` 类配置控制是否暴露 `run_in_background` 参数,然后调用 `ctx.tasks.start({ kind, label, owner: exec.agent, run: () => ({ cancel, done, readOutput? }) })`(`@deepseek-ai/dsh-tasks`)。运行时会在调用你的 `run()` 启动器之前,预检所有可能失败的条件(控制面围栏、校验和 owner cleanup 挂接),从结构上杜绝工作已经启动却没有可收集 id 的情况(你的工具无需通过 try/catch 回滚)。运行时签发 `-N` id,将访问限制在 owner 会话,并在 owner dispose 时取消并等待任务;通用的 `task_output`、`task_list`、`task_kill` 工具和完成通知由 `@deepseek-ai/dsh-tool-tasks` 提供,你的工具返回 `started background task ` 后即完成。producer 保留自身的执行职责:`done` 必须在达到静止状态(资源已释放)后 settle;流类型的 `readOutput` 自行负责截断和溢写格式(限定缓冲区大小,将完整输出溢写到磁盘,避免静默丢失——参见 tool-bash 的 `renderProcessRead`)。返回 id 后,不要再把 `exec.signal` 连接到后台工作;调用 `start` 前只检查一次 `exec.signal?.aborted`,此后由 `task_kill` 和 owner cleanup 负责取消。 +通过 producer 配置控制 `run_in_background`,拒绝已预先中止的调用,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 + +producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。 ## 执行策略与观测 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 58ecfe9ef1..19c09d944b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -56,12 +56,12 @@ Source: [`packages/ui/user-approval/src/index.ts:229`](../../packages/ui/user-ap Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). -Semantics every implementation must honor: +Implementations must honor these semantics: -- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception. -- start returns immediately; no timeout applies to background processes (callers stop them via BashProcess.kill or the spec's AbortSignal). The handle's `done` settles at process close and never rejects (a spawn failure settles as `killed` with the error readable on stderr). -- BashProcess.readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available. -- Disposal kills every running background process and awaits their exit (no orphan processes survive `fiber.dispose()`). +- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. +- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. +- BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. +- Disposal kills all running background processes and awaits their exit. ```ts cordis-catalog abstract resolve(request: BashExecRequest): BashExecSpec @@ -71,7 +71,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:68`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:46`](../../packages/bash/bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -259,7 +259,7 @@ attachSurface(name: string): () => void Types: [Agent](../core-data-structures/core.md) -Source: [`packages/tasks/tasks/src/index.ts:98`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:72`](../../packages/tasks/tasks/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 80dd7fc52e..0f84ac33b4 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -1,16 +1,14 @@ # Background Task Runtime -The shared background-task vocabulary — what a producer (`dsh-tool-bash`, `dsh-tool-subagent`, any future long-running tool) hands to `ctx.tasks.start()` and what consumers (the `task_output`/`task_list`/`task_kill` tools, completion-notice injection) get back. The runtime is ONE concrete service ([dsh-tasks](../../packages/tasks/tasks), `ctx.tasks`), not an interface/implementation seam pair — see [the runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) for the decision and [the tasks group README](../../packages/tasks/README.md) for the package split. - -Source: [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) +Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status -`TaskId` is [branded](core.md#branded-ids) (`Branded<'TaskId'>` + a same-named factory), generated by the registry as `-N` with a per-kind counter (`bash-1`, `subagent-1`) — kind-prefixed so transcripts stay self-describing, sequential because the owner fence (not id secrecy) is the isolation boundary. `TaskStatus` is generic and CLOSED: `'running' | 'stopping' | 'completed' | 'killed' | 'failed'` — kind-specific meaning (exit codes, stop reasons) rides in `TaskSnapshot.detail`, so the registry never learns process or agent semantics. +`TaskId` is a [branded id](core.md#branded-ids) generated as `-N`. Access control relies on owner authorization, not id secrecy. `TaskStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `TaskSnapshot.detail`. -## The producer contract: `TaskStart` and `TaskHooks` +## Producer contract -Declare-then-execute: the producer hands its task's identity plus a `run()` starter to `ctx.tasks.start()`, which preflights everything that can fail (the control-surface fence, validation, the owner-cleanup attach) BEFORE invoking `run()`, and commits atomically after — work that started without a collectable id is structurally impossible. The producer stays the owner of its execution concerns (process streams, child agents); the runtime owns ids, isolation, status, and completion fan-out. The optional `readOutput` hook marks a STREAM kind — the method presence is the capability, mirroring `SubagentRun.sendMessage`. +`TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv interface TaskStart { @@ -19,53 +17,41 @@ interface TaskStart { /** One-line model-facing label (the command; the delegation description). */ label: string /** - * The spawning agent. Its `session.header.id` becomes the task's owner - * identity (read/kill/wait/list are fenced to that session), and its `ctx` scope - * owns an async cleanup that cancels and awaits the task during disposal. It - * must be the exact live instance currently registered under its agent id; - * a stale object whose id has been reused is rejected before work starts. - * `undefined` starts an UNOWNED task: open to any caller, alive until the - * tasks service disposes. + * Owning live agent. Access is fenced by its session id, and agent disposal + * cancels and awaits the task. The instance must be the one currently + * registered under its agent id. `undefined` creates an unowned task, open to + * any caller until service disposal. */ owner?: Agent | undefined /** - * Start the actual work and return its {@link TaskHooks}. Called EXACTLY - * once, synchronously, after every preflight check (control-surface fence, - * validation, owner-cleanup attach) has passed — nothing in the runtime can - * fail after it returns, so the started work is always registered. A throw - * here propagates with nothing registered; the producer owns any partial - * cleanup of its own failed start. + * Start the work after preflight and synchronously return its hooks. Called + * once; a throw leaves nothing registered, and the producer must clean up any + * partially started resources. */ run(): TaskHooks } ``` +`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. + ```ts type-equiv interface TaskHooks { /** - * Request termination. Idempotent, synchronous, and must lead to - * {@link done} settling; a throw propagates to the killer (fail loud — a - * cancel that cannot even be requested is a producer bug). The optional - * reason is `task_kill`'s logged reason, forwarded verbatim. + * Request termination. Must be synchronous, idempotent, and eventually settle + * {@link done}; throws propagate. The optional reason is forwarded verbatim. */ cancel(reason?: string): void /** - * Settles with the terminal outcome at QUIESCENCE — after the producer has - * released the task's resources (process exited, child agent disposed) — - * not merely when the work finished. Must never reject; a rejection is - * contained as a `failed` outcome and logged as a producer contract - * violation. If `cancel` throws during teardown, the runtime may force-fail - * only its registry record to avoid deadlock because this promise may never - * settle; that fallback explicitly does not claim work quiescence. + * Resolves after the producer releases its resources, not merely when work + * finishes. Must not reject; the runtime converts a rejection to `failed`. + * If teardown cancellation throws, the runtime may force-fail only the + * registry record without claiming that the work stopped. */ done: Promise /** - * OPTIONAL incremental read (stream kinds): everything produced since the - * previous call, formatted by the producer (truncation/spill notices - * included). Consecutive calls never re-deliver output; the registry keeps - * ONE consuming cursor per task, so v1's single intended reader is the - * owning model. Absence marks a final-output-only kind (the method presence - * IS the capability). + * Consume output produced since the previous call. The producer formats + * truncation and spill notices. Absence marks a final-output-only task; each + * task has one consuming cursor. */ readOutput?(): string } @@ -77,18 +63,14 @@ interface TaskOutcome { status: 'completed' | 'killed' | 'failed' /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ detail?: string - /** - * Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskHooks.readOutput}), - * read idempotently after the task settles. Stream kinds leave it unset — - * their output is consumed incrementally through `readOutput`. - */ + /** Final output for tasks without `readOutput`; stream tasks leave it unset. */ output?: string } ``` -## What consumers see: `TaskSnapshot` and `TaskRead` +## Consumer views -Snapshots are fresh projections, never live registry state. `ownerSession` retains the shared branded `SessionId` type across the package boundary. `reported` is the notice-suppression flag: the completion-notice injector (`dsh-tool-tasks`) skips a task whose terminal state the model already saw. +Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state. ```ts type-equiv interface TaskSnapshot { @@ -99,12 +81,9 @@ interface TaskSnapshot { /** The producer-supplied one-line label. */ label: string /** - * The owner's session id (`session.header.id`), for authorization and - * correlation; absent for unowned tasks. A listener that must reach the - * lifecycle owner receives the exact Agent separately through - * {@link TaskDoneListener}. Session ids are runtime-shared identifiers, not - * secrets — the read/kill/wait/list FENCE is what isolation rests on. The - * shared {@link SessionId} brand is preserved across this package boundary. + * Owner session id used for authorization and correlation; absent for + * unowned tasks. Completion listeners receive the exact {@link Agent} + * separately through {@link TaskDoneListener}. */ ownerSession?: SessionId /** Current lifecycle state. */ @@ -116,11 +95,8 @@ interface TaskSnapshot { /** Epoch ms when the task settled; absent while `running`/`stopping`. */ finishedAt?: number /** - * True once the terminal state has been (or is being) reported to the owner - * through an explicit surface response — a `kill` call, or a `read`/`wait` - * that returned the terminal state (including a wait pending at settlement). - * Completion-notice surfaces suppress their notice when set, so the model - * never gets a redundant "finished" for a task it just collected or killed. + * True when a kill, read, or wait has reported or committed to report the + * terminal state. Completion surfaces suppress redundant notices when set. */ reported: boolean } @@ -139,6 +115,6 @@ interface TaskRead { } ``` -## The service +## Service behavior -`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per terminal record, given the exact lifecycle owner and effect-scoped). Listener calls contain synchronous throws and returned promise rejections independently; returned promises are not awaited and therefore do not delay task settlement. Start retains the exact live Agent instance validated under its id; owner-scope cleanup selects by that identity, so a reused agent/session id cannot make an old scope cancel replacement work. Read/kill/wait/get authorization remains session-based and rejects a foreign session. A teardown cancel that throws force-fails only the registry record and reports that the underlying work may be orphaned, preventing disposal deadlock without claiming quiescence. The model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md). +[`TaskService`](../../packages/tasks/tasks/src/index.ts) provides atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the package contract and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5761899938..dd26596a32 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -127,7 +127,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [The background task runtime (`ctx.tasks`) and the generic task control tools](implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [The background task runtime (`ctx.tasks`) and generic task control tools](implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 | | [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | diff --git a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 6242aa97da..64c5dff5a4 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -1,185 +1,128 @@ -# RFC: The background task runtime (`ctx.tasks`) and the generic task control tools +# RFC: The background task runtime (`ctx.tasks`) and generic task control tools Status: implemented ## Problem -The bash capability seam supports both foreground commands and long-running background tasks. Background support was large: the abstract executor exposed `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracked tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model saw three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injected completion notices back into the owning agent's session. The local executor fenced task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. +Background bash originally combined two responsibilities: the bash executor ran processes and also managed task ids, ownership, incremental reads, cancellation, completion listeners, and model-facing control tools. Adding background subagents required the same lifecycle and interaction contract. Implementing that contract independently for every long-running capability would duplicate isolation, cleanup, notification, and prompt behavior while teaching the model a different collect-and-stop protocol for each producer. -The [tool cookbook](../../../cookbook/adding-a-tool.md) already pointed at the real design smell: background bash is really generic long-running-tool infrastructure living inside one tool. The pressure stopped being hypothetical with [background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md), which needs the same task ids, owner isolation, polling, stop, completion notices, and prompt guidance, and whose first draft answered by cloning the protocol under new names (`subagent_wait`, `subagent_output`, `subagent_stop`) and reshaping `dsh-tool-subagent` into a multi-tool plugin solely so the cloned companion tools would not collide across instances. Every future long-running capability (dev servers, watchers, remote jobs) would clone it again, and the model would learn a new collect/stop habit per capability. - -The surveyed peer products converged on the opposite shape. Claude Code exposes one `TaskOutput`/`TaskStop` pair spanning seven task kinds (background shells, subagents, remote sessions, …), with its earlier per-capability `BashOutput`/`KillShell` names kept only as aliases; Kimi Code's `BackgroundManager` runs process, agent, and pending-question kinds behind the same two tools and a ~5-method producer interface; DeepSeek-Reasonix serves bash and delegation from one session-scoped jobs manager; OpenCode's `BackgroundJob` registry is kind-agnostic by construction. The lesson is that the task registry, the control tools, and the notification path are one capability, and the producers (bash, subagents) are plugins into it. +The task registry, control tools, and completion notices form one harness capability. Bash and subagents should supply execution-specific hooks without owning generic task behavior. ## Decision -The `tasks/` package group owns background-task semantics once, and bash and subagents are producers: +The `tasks/` package group owns background-task semantics: -- `@deepseek-ai/dsh-tasks` — the task registry service (`ctx.tasks`): branded task ids, owner-scoped authorization, status snapshots, incremental/final output reads, cancellation, wait-for-terminal, completion listeners, and the awaited owner-cleanup path. -- `@deepseek-ai/dsh-tool-tasks` — the model-facing control surface: `task_output`, `task_list`, `task_kill`, the completion-notice injection into the owning session, and the system-prompt section that teaches the background-task habit. +- `@deepseek-ai/dsh-tasks` registers running work as `ctx.tasks` and owns task ids, authorization, snapshots, reads, cancellation, waiting, completion listeners, and cleanup. +- `@deepseek-ai/dsh-tool-tasks` exposes `task_output`, `task_list`, and `task_kill`, injects completion notices, and supplies the background-task system-prompt guidance. -Producers register running work into `ctx.tasks` and stay owners of their execution concerns: `dsh-tool-bash`'s `run_in_background` path registers the process it started (incremental stdout, spill formatting, kill), and `dsh-tool-subagent`'s background mode ([the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)) registers the child run (final output only, cancel + dispose). The bash seam carries no registry: `bash_output`/`bash_kill` no longer exist (the generic tools replaced them), and the subagent companion tools were never created. The `dsh-agent-core` bundle loads the pair, so every shipped deployment has the control surface. +Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry. -The registry is a CONCRETE service, not an interface/implementation seam pair: there is exactly one sensible in-process implementation today, and the capability-seam convention says not to split preemptively. The pre-release stance lets a later durable/remote job system extract an interface when a second backend actually exists. +`TaskService` is a concrete service. There is one in-process implementation, so an interface/backend package split would be speculative. A durable or remote implementation can introduce that seam when its lifecycle requirements are known. -## Task model +## Runtime contract -`dsh-tasks` owns the vocabulary ([data-structure catalog](../../../core-data-structures/tasks.md)). `TaskId` is branded, generated by the registry as `-N` with a per-kind counter (`bash-1`, `subagent-1`) — the kind prefix keeps ids self-describing in transcripts and preserves the pre-runtime `bash-N` shape. Ids are runtime-global and predictable, so every access is authorized (below). +The literal types live in the [task data-structure catalog](../../../core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. -A producer hands its work to `ctx.tasks.start()` in a declare-then-execute shape (the pattern the timeout-policy plugin set: the capability declares, the shared layer executes): identity first, then a `run()` starter the runtime invokes only once nothing can fail anymore. +The producer hooks define three responsibilities: -```ts ignore-check -interface TaskStart { - /** Producer kind — also the id prefix ('bash', 'subagent', …). */ - kind: string - /** One-line model-facing label (the command; the delegation description). */ - label: string - /** The spawning agent; undefined = unowned (open access, dies with the service). */ - owner?: Agent - /** Start the actual work; called exactly once, after preflight passed. */ - run(): TaskHooks -} +- `cancel(reason?)` synchronously requests termination, is idempotent, and must cause `done` to settle. +- `done` never rejects and settles only after the producer has released the task's resources. +- Optional `readOutput()` returns the next consuming output delta. Omitting it declares a final-output task whose terminal result comes from `TaskOutcome.output`. -interface TaskHooks { - /** Request termination; idempotent; must lead to `done` settling. The optional reason is `task_kill`'s logged reason, forwarded. */ - cancel(reason?: string): void - /** Settles at QUIESCENCE — after the producer has released the task's resources. Never rejects. */ - done: Promise - /** OPTIONAL incremental read (stream kinds). Consecutive calls never re-deliver output; the producer owns truncation/spill formatting. Absence = final-output-only kind. */ - readOutput?(): string -} +Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Producer-specific information such as an exit code or stop reason belongs in `detail`; the registry does not interpret it. Task ids are branded and generated as `-N`, with a counter per kind. -interface TaskOutcome { - status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into the status line ('exit code: 3', 'max-tokens'). */ - detail?: string - /** Final output for final-only kinds; read idempotently after the task settles. */ - output?: string -} -``` +The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop. -The task status vocabulary is generic and closed: `running`, `stopping` (cancel requested, not yet settled), and the three terminal values above. Kind-specific meaning rides in `detail`, so the registry never learns process or agent semantics — the method presence (`readOutput`) is the capability, mirroring `SubagentRun.sendMessage`. +Task registrations are not effects of the producer tool fiber. Reloading a tool or control-surface plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers. -The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — makes ordinary owner cleanup and service disposal awaitable without a second completion surface. Settlement is first-wins because teardown has one explicit failure fallback: if producer `cancel` throws before the request is delivered, `done` may never settle, so the registry force-fails the record with a possible-orphan detail instead of deadlocking disposal; a late producer outcome cannot overwrite that diagnosis or notify twice. This fallback terminates bookkeeping, not necessarily the underlying work. +## Authorization and owner lifecycle -Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits contract-compliant producers to quiescence. A teardown cancel that throws force-fails the terminal record and logs that work may be orphaned; this prevents a broken producer from deadlocking the fiber without pretending the work stopped. +Task ids are runtime-global and predictable, so every access is authorized by the registry. `get`, `read`, `wait`, and `kill` accept the calling `Agent`; `list` returns only tasks visible to that caller. An owned task is accessible only to the exact owning session. Unowned tasks are open to non-agent callers and die with the task service. -## Authorization and the service surface +The snapshot stores the owner's branded `SessionId` for authorization, while lifecycle operations retain the exact live `Agent` instance. These identities serve different purposes: session equality grants access, but exact object identity selects cleanup and completion delivery. Reusing an agent or session id cannot redirect an old scope's cleanup or notices to a replacement. -Cross-session isolation lives IN the runtime so every consumer gets the same rule for free: read/kill/wait/get take the caller (`Agent | undefined`), and a task whose owner session differs from the caller's session is rejected (`!== undefined` comparison — an unowned task is open, a no-agent caller cannot match an owned one). `list(caller)` returns only the caller-visible tasks (owned-by-caller or unowned) — a global listing would leak other sessions' labels. The snapshot carries that authorization identity as the canonical branded `SessionId`, not a package-local token or bare string. Lifecycle ownership is independent: start retains the exact live `Agent` instance, owner cleanup selects by object identity, and completion listeners receive that exact owner, so id reuse cannot redirect cleanup or notices to a replacement. +The first task for an owner attaches one asynchronous effect to `owner.ctx`. Agent-scope disposal cancels that owner's live tasks, awaits their terminal records, and removes their snapshots. This effect survives producer reloads and joins the agent's existing quiescence boundary. The task service retains the effect disposer so service reload can detach callbacks from still-live agent scopes after global teardown. -```ts ignore-check -class TaskService extends Service { // ctx.tasks - start(spec: TaskStart): TaskId // preflight (throws) → spec.run() starts the work → atomic commit (cannot fail) - get(id: TaskId, caller?: Agent): TaskSnapshot // non-consuming; throws: unknown id, foreign owner - list(caller?: Agent): TaskSnapshot[] // caller-visible only - read(id: TaskId, caller?: Agent): TaskRead // delta (stream kinds, consuming) or final output (final kinds, idempotent) + snapshot - kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-terminal' - wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - onTaskDone(listener: (snapshot: TaskSnapshot, owner: Agent | undefined) => void | PromiseLike): () => void // exact owner; effect-scoped, contained - attachSurface(name: string): () => void // the misconfiguration fence, below -} -``` +For contract-compliant producers, `AgentHandle.dispose()` resolves only after owned background work has stopped. Work intended to outlive an agent must be started unowned; survival across runtime restarts requires a separate durable-job design. -`TaskSnapshot` is the read-only projection: id, kind, label, branded owner `SessionId`, status, detail, started/finished timestamps, and the `reported` notice-suppression flag (below). `wait` resolves with the terminal snapshot, or with the still-`running` snapshot on timeout; aborting the wait cancels only the wait — unless the task already settled, in which case the wait still delivers the terminal snapshot (settlement suppressed the completion notice on this live waiter's behalf, and an aborted waiter un-counts itself synchronously so a same-tick settlement never suppresses a notice nobody will deliver). +## Service surface -**Misconfiguration fails loud**: a deployment that loads a background-capable producer without any control surface would let the model start tasks it can never read or stop — the half-loaded failure mode the subagent RFC's first draft reshaped a whole plugin to avoid. The fence is `attachSurface()`: `dsh-tool-tasks` attaches (effect-scoped) on load, and `start()` throws `background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)` when none is attached — the earliest self-contained moment, since concurrent plugin start makes a load-time check racy. The registry stays ignorant of tool names; a deployment with a custom (non-model) surface attaches its own. +`TaskService` provides: -## The model-facing control tools +- `start(spec)` for preflighted, atomic registration. +- `get(id, caller?)` and `list(caller?)` for non-consuming snapshots. +- `read(id, caller?)` for a consuming stream delta or an idempotent final result. +- `kill(id, caller?, reason?)` for cancellation. +- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting. +- `onTaskDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment. +- `attachSurface(name)` for the control-surface availability fence. -`dsh-tool-tasks` registers three kind-agnostic tools (ACP render intent: `generic` cards, `kind: 'execute'` for kill and `'read'` for output/list, no `locations`): +`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing. -- `task_output(task_id, wait?, timeout_ms?)` — non-blocking by default: stream kinds return output produced since the previous read, final kinds return only a status line while running and the final output once terminal; every response ends with the status line (`[status: running]`, `[status: completed, exit code: 0]`, `[status: failed, max-tokens]` — generic status + producer detail). `wait: true` blocks until the task settles or the timeout expires (config: defaulted `waitTimeoutMs`, capped `maxWaitTimeoutMs`); a timed-out wait returns `[status: running]` and leaves the task alive. Polling-by-default preserves the established bash habit; `wait` is what a parent uses when it is genuinely blocked on a subagent's answer. -- `task_list()` — the caller's tasks, one line each: ` []