mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(tasks): declare-then-execute — ctx.tasks.start() replaces register()
start({ kind, label, owner, run }) preflights everything that can fail
(the attachSurface fence, validation, the owner-cleanup attach) BEFORE
invoking the producer's run() starter, then commits atomically —
'work started but never got a collectable id' is now structurally
impossible instead of a producer try/catch rollback obligation (the
P1 review fix, rebuilt on #185's declare/execute split). Producers
lose their catch-wraps; the leak tests now pin the stronger property
that a failed preflight never spawns anything. TaskRegistration splits
into TaskStart (identity + run) and TaskHooks (cancel/done/readOutput);
docs, type-equiv manifest, catalogs, and both RFCs move with it.
This commit is contained in:
@@ -41,7 +41,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
|
||||
|
||||
## Long-running work
|
||||
|
||||
Register the running work with 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, start the work, and hand it to `ctx.tasks.register({ kind, label, owner: exec.agent, cancel, done, readOutput? })` (`@deepseek-ai/dsh-tasks`). The runtime issues the `<kind>-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 <id>` 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 starting, then leave cancellation to `task_kill` and owner cleanup. **A failed `register()` must not orphan the work**: `register()` is atomic (a throw — the no-control-surface fence, a bad owner — mutates no registry state), so wrap it in try/catch, cancel the just-started work, AWAIT its quiescence, and rethrow — the model never learns an id, so nothing else could ever collect or kill what you started (tool-bash's `proc.kill(); await proc.done` and tool-subagent's `run.cancel(); await done` are the templates).
|
||||
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 `<kind>-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 <id>` 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.
|
||||
|
||||
## Permissions / sandboxing
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/sys
|
||||
The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
|
||||
|
||||
```ts cordis-catalog
|
||||
register(registration: TaskRegistration): TaskId
|
||||
start(spec: TaskStart): TaskId
|
||||
list(caller?: Agent): TaskSnapshot[]
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot
|
||||
read(id: TaskId, caller?: Agent): TaskRead
|
||||
@@ -224,7 +224,7 @@ attachSurface(name: string): () => void
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/tasks/tasks/src/index.ts:93`](../../packages/tasks/tasks/src/index.ts)
|
||||
Source: [`packages/tasks/tasks/src/index.ts:95`](../../packages/tasks/tasks/src/index.ts)
|
||||
|
||||
## `ctx.tools` — `ToolRegistry`
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, the background `BashProcess` handle |
|
||||
| [tasks.md](tasks.md) | the background task runtime: `TaskId`, `TaskRegistration`, `TaskOutcome`, `TaskSnapshot`/`TaskRead`, owner isolation, the control-tool surface |
|
||||
| [tasks.md](tasks.md) | the background task runtime: `TaskId`, `TaskStart`/`TaskHooks`, `TaskOutcome`, `TaskSnapshot`/`TaskRead`, owner isolation, the control-tool surface |
|
||||
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
|
||||
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
|
||||
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.register()` 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.
|
||||
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)
|
||||
|
||||
@@ -8,12 +8,12 @@ Source: [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/typ
|
||||
|
||||
`TaskId` is [branded](core.md#branded-ids) (`Branded<'TaskId'>` + a same-named factory), generated by the registry as `<kind>-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.
|
||||
|
||||
## The producer contract: `TaskRegistration`
|
||||
## The producer contract: `TaskStart` and `TaskHooks`
|
||||
|
||||
A producer starts its work, then hands the running work over. The producer stays the owner of its execution concerns (process streams, child agents); the registry owns ids, isolation, status, and completion fan-out. The optional `readOutput` marks a STREAM kind — the method presence is the capability, mirroring `SubagentRun.sendMessage`.
|
||||
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`.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskRegistration {
|
||||
interface TaskStart {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). Non-empty. */
|
||||
kind: string
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
@@ -22,10 +22,24 @@ interface TaskRegistration {
|
||||
* The spawning agent. Its `session.header.id` becomes the task's owner
|
||||
* token (read/kill/wait/list are fenced to that session), and its disposal
|
||||
* cancels and awaits the task through the `ctx.agents.onCleanup` seam.
|
||||
* `undefined` registers an UNOWNED task: open to any caller, alive until the
|
||||
* `undefined` starts an UNOWNED task: open to any caller, alive until the
|
||||
* tasks service disposes.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
run(): TaskHooks
|
||||
}
|
||||
```
|
||||
|
||||
```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
|
||||
@@ -53,8 +67,6 @@ interface TaskRegistration {
|
||||
}
|
||||
```
|
||||
|
||||
`register()` is ATOMIC: a throw (the no-control-surface fence, an owner-cleanup attach failure) mutates no registry state, so the producer cancels and awaits its just-started work and rethrows — background work never runs without a collectable id.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskOutcome {
|
||||
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||
@@ -62,7 +74,7 @@ interface TaskOutcome {
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
detail?: string
|
||||
/**
|
||||
* Final output for FINAL-OUTPUT-ONLY kinds (no {@link TaskRegistration.readOutput}),
|
||||
* 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`.
|
||||
*/
|
||||
@@ -123,4 +135,4 @@ interface TaskRead {
|
||||
|
||||
## The service
|
||||
|
||||
`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `register` (atomic, 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 settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
|
||||
`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 settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md).
|
||||
|
||||
@@ -144,6 +144,7 @@ flowchart TD
|
||||
pkg_user_interaction --> pkg_llm
|
||||
pkg_tasks --> pkg_agent
|
||||
pkg_tasks --> pkg_brand
|
||||
pkg_tasks --> pkg_timeout
|
||||
pkg_agent_loop --> pkg_agent
|
||||
pkg_agent_loop --> pkg_llm
|
||||
pkg_agent_loop --> pkg_session
|
||||
@@ -282,7 +283,7 @@ flowchart TD
|
||||
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -25,16 +25,21 @@ The registry is a CONCRETE service, not an interface/implementation seam pair: t
|
||||
|
||||
`dsh-tasks` owns the vocabulary ([data-structure catalog](../../../core-data-structures/tasks.md)). `TaskId` is branded, generated by the registry as `<kind>-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).
|
||||
|
||||
A producer registers a task with:
|
||||
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.
|
||||
|
||||
```ts ignore-check
|
||||
interface TaskRegistration {
|
||||
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
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -64,7 +69,7 @@ Cross-session isolation lives IN the runtime so every consumer gets the same rul
|
||||
|
||||
```ts ignore-check
|
||||
class TaskService extends Service { // ctx.tasks
|
||||
register(reg: TaskRegistration): TaskId // throws when no control surface is attached; ATOMIC — a throw mutates nothing
|
||||
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
|
||||
@@ -77,7 +82,7 @@ class TaskService extends Service { // ctx.tasks
|
||||
|
||||
`TaskSnapshot` is the read-only projection: id, kind, label, owner session, 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.
|
||||
|
||||
**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 `register()` 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.
|
||||
**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.
|
||||
|
||||
## The model-facing control tools
|
||||
|
||||
@@ -95,7 +100,7 @@ Completion notices stay durable context, not a wake-up (`agent.inject()` appends
|
||||
|
||||
## Producer opt-in and schema exposure
|
||||
|
||||
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `register()` without a control surface throws the load-this-package error. `register()` is atomic (a throw mutates no registry state), and a producer whose registration fails cancels and awaits its just-started work before rethrowing — background work never runs without a collectable id.
|
||||
Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely, so schema and capability can never disagree. `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation.
|
||||
|
||||
## The awaited owner-cleanup seam
|
||||
|
||||
@@ -110,11 +115,11 @@ A background task must not outlive its owner: the subagent case leaks live child
|
||||
|
||||
`dsh-bash` keeps the execution contract and carries no registry. The seam is `resolve`, `run`, and `start`, where `start(spec)` returns a process handle — `BashProcess`: `{ command, status, exitCode, signal, done, readOutput(), kill() }` — instead of a registry entry: `get`/`ownerOf`/`list`/`onTaskDone`, the listener machinery, `BashTaskId`, `OwnerToken`, and the spec's `owner` field are gone (a consumer census found `get`/`list` reached only by test harnesses and `onTaskDone` single-consumer — `dsh-tool-bash`; the hook bridges consume `resolve`+`run` only). The local executor keeps an internal table of LIVE processes solely for its own disposal quiescence (entries leave on settlement). The foreground trusted-plugin path (`resolve` + `run` with `stdin`/`env`, used by the hook bridges) is untouched and never routes through the runtime; `BashExecSpec.timeoutMs` stays required-but-ignored by `start()` (shared-spec status quo, documented in the seam JSDoc); the credential-scrub duplication between the bash and ACP spawn sites is explicitly NOT this runtime's work — the registry never touches process spawning.
|
||||
|
||||
`dsh-tool-bash` keeps the `bash` tool; the `run_in_background` path is `ctx.bash.start(...)` + `ctx.tasks.register({ kind: 'bash', label: command, owner: exec.agent, cancel, done, readOutput })`, where `done` maps the process exit to a `TaskOutcome` (`processOutcome`: `completed`/`killed` + exit-code/signal detail) and `readOutput` wraps the handle's incremental read with the spill/lossy formatting (`renderProcessRead`). The completion-notice listener left `dsh-tool-bash` entirely.
|
||||
`dsh-tool-bash` keeps the `bash` tool; the `run_in_background` path is `ctx.tasks.start({ kind: 'bash', label: command, owner: exec.agent, run })` whose `run()` spawns through `ctx.bash.start(...)` and returns the hooks, where `done` maps the process exit to a `TaskOutcome` (`processOutcome`: `completed`/`killed` + exit-code/signal detail) and `readOutput` wraps the handle's incremental read with the spill/lossy formatting (`renderProcessRead`). The completion-notice listener left `dsh-tool-bash` entirely.
|
||||
|
||||
## Subagent integration
|
||||
|
||||
[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call starts the provider run, then registers `{ kind: 'subagent', label: description, owner: parent, cancel: run.cancel, done }` where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
|
||||
[Background subagent tasks](../feature/2026-07-08-background-subagent-tasks.md) rides this runtime; the headline consequence is that `dsh-tool-subagent` KEEPS its one-instance-per-provider shape — the multi-tool reshape existed only to keep cloned companion tools from colliding, and there are no companion tools to clone. Its background call is `ctx.tasks.start({ kind: 'subagent', label: description, owner: parent, run })` whose `run()` starts the provider run and returns `{ cancel: run.cancel, done }`, where `done` awaits `run.result`, awaits `run.dispose()` (quiescence), and maps the stop reason (`completed` → `completed`; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as detail) and the final text as `output`. No `readOutput` — the child session remains the detailed trace, exactly as that RFC argues.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -164,7 +169,7 @@ Everything model-visible already lands in the log: starts and reads are tool cal
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, register atomicity — a failed registration mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' registration mapping plus their no-orphan guarantee (a failed `register()` kills/cancels and awaits the just-started work before rethrowing), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
||||
Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' start mapping plus the structural no-orphan guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background?: boolean`, gate
|
||||
|
||||
A foreground call keeps the synchronous semantics: it waits for `run.result`, returns final text on `completed`, maps non-clean terminal stop reasons to an errored tool result, and disposes the run in `finally`.
|
||||
|
||||
A background call validates that a parent agent exists, checks an already-aborted tool signal before starting, starts the provider run through `ctx.subagents`, registers the run with `ctx.tasks`, and returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. A registration that throws (the no-control-surface fence) does not orphan the child: the producer cancels the run, awaits its `done` (which settles only after `run.dispose()`), and rethrows — the model never learns an id for work that is not actually tracked. `ctx.tasks.register()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
|
||||
A background call validates that a parent agent exists, checks an already-aborted tool signal, and hands the delegation to `ctx.tasks.start()` — the runtime preflights the control-surface fence and the owner cleanup BEFORE its `run()` starter creates the child through `ctx.subagents`, so a child that started without a collectable id is structurally impossible — then returns `started background subagent task <task_id>`. After the id is returned the tool-call signal is NOT connected to `run.cancel()` — the parent step may finish while the child continues; cancellation belongs to `task_kill` and the owner-cleanup path. `ctx.tasks.start()` supplies the runtime guarantees this feature needs and this RFC does not implement: kind-prefixed branded task ids, owner-scoped access (the parent agent is the owner; another session's agent cannot read or kill the task), the loud no-control-surface failure, completion-notice injection, and the generic prompt guidance.
|
||||
|
||||
The registration maps the seam vocabulary onto the runtime's:
|
||||
|
||||
@@ -53,10 +53,10 @@ The child session is already the trace for internal reasoning, tool calls, and i
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the no-orphan rollback when `register()` throws, per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
|
||||
Unit coverage pins the stop-reason → outcome mapping (`runOutcome`, including unknown merge-extensible reasons), `settleRun`'s dispose-before-report on both result paths, the detached-signal contract (a pre-aborted signal refuses to start; a returned id is never wired to the tool signal), background settlement collected through the real `task_output`/`task_kill` tools, the structural no-orphan guarantee (a failed `tasks.start` preflight never invokes the provider), per-instance schema gating (`enableRunInBackground: false` omits the parameter and the background wording), and the loud failure when the tasks runtime is absent. Snapshot coverage pins the changed `subagent`/`subagent_fork` schemas through the pinned-header fixture; recording a live background-delegation transcript requires a `DEEPSEEK_API_KEY` re-record and remains named follow-up work.
|
||||
|
||||
## Consequences
|
||||
|
||||
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `register()` fence turns a missing control surface into a loud, actionable error rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
|
||||
Slow delegation no longer holds the parent step open: the model fans out background children, keeps working, and collects with the same three control tools it already uses for bash — no new habit, no schema clones, and `dsh-tool-subagent`'s per-provider shape survived unchanged. The feature's usability depends on the tasks pair being loaded; the runtime's `start()` preflight fence turns a missing control surface into a loud, actionable error (raised before any child exists) rather than a silent dead end, and the `dsh-agent-core` bundle ships the pair so every stock deployment has it.
|
||||
|
||||
The prompt guidance reduces abandoned tasks but cannot force a model to collect every background result. Runtime cleanup through the awaited owner-disposal path is the hard stop; a future planner or guard could enforce "no final answer with relevant running tasks" more strongly if the prompt proves insufficient. A background child outliving its starting tool call means a misbehaving child consumes tokens until collected, killed, or owner-disposed; `task_list` keeps it visible, and setting `enableRunInBackground: false` per instance keeps a deployment's delegation strictly synchronous.
|
||||
|
||||
@@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
|
||||
| `@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 `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`. |
|
||||
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent 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 or ACP plan. |
|
||||
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
|
||||
|
||||
@@ -332,7 +332,7 @@ Read output/status from a background task (started by a tool with `run_in_backgr
|
||||
|
||||
Source: [`packages/tasks/tool-tasks/src/index.ts`](../packages/tasks/tool-tasks/src/index.ts)
|
||||
|
||||
The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.register()`.
|
||||
The kind-agnostic background-task control surface: a background bash command and a background subagent 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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user