docs(tasks): condense background task prose
The background-task change repeated its lifecycle design across implemented RFCs, package READMEs, JSDoc, test commentary, and model-visible schemas. That repetition obscured the contracts that maintainers must preserve and added avoidable prompt tokens. Rewrite the implemented RFCs around the current design, keep authorization, exact-owner cleanup, wait/abort ordering, producer quiescence, and teardown-failure guarantees at their owning surfaces, and remove peer surveys, review history, control-flow narration, and emphatic restatement. Shorten the task and subagent schema wording, synchronize the bilingual tool cookbook, and regenerate the config, service, RFC, tool, and replay snapshot derivatives. Runtime behavior is unchanged; test edits update prose-only assertions and descriptions.
This commit is contained in:
@@ -1,35 +1,35 @@
|
||||
# @deepseek-ai/dsh-tool-tasks
|
||||
|
||||
The model-facing background task control surface over `ctx.tasks`: three kind-agnostic tools, the completion-notice injection, and the prompt section that teaches the background habit. Loading this plugin calls `ctx.tasks.attachSurface('tool-tasks')`, which is what arms producers' `ctx.tasks.start()`.
|
||||
The model-facing control surface for `ctx.tasks`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the surface required by `ctx.tasks.start()`.
|
||||
|
||||
## Tools
|
||||
|
||||
- `task_output(task_id, wait?, timeout_ms?)` — non-blocking read by default (stream kinds: the consuming delta since the previous read; final kinds: the final answer once terminal); every response ends with a `[status: …]` line (generic status + producer detail, e.g. `[status: completed, exit code: 0]`). `wait: true` blocks until settlement, bounded by `waitTimeoutMs`/`maxWaitTimeoutMs` config; a timed-out wait returns `[status: running]` and leaves the task alive.
|
||||
- `task_list()` — the caller's tasks, `<id> [<kind>] <status> — <label>` per line.
|
||||
- `task_kill(task_id, reason?)` — requests cancellation and returns immediately; the logged `reason` is forwarded to the producer. An already-terminal task is described via a non-consuming snapshot (never eats a pending delta).
|
||||
- `task_output(task_id, wait?, timeout_ms?)` reads without blocking by default. Stream tasks return only the next delta; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running task alive on timeout.
|
||||
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`.
|
||||
- `task_kill(task_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal tasks return a non-consuming snapshot.
|
||||
|
||||
ACP render intent: all three are `generic` cards (`read`/`read`/`execute`) — a task read is not a terminal.
|
||||
All three use generic ACP cards: `read` for output and list, `execute` for kill.
|
||||
|
||||
## Completion notices
|
||||
|
||||
On `onTaskDone`, injects `background task <id> (<kind>: <label>) finished [status: …]. Read its output with task_output.` through the exact owner `Agent` captured at task start (`agent.inject()` — durable context for the next request, not a wake-up). It never re-resolves a reusable agent/session id to a replacement. Suppressed when the snapshot is `reported` (the model already killed it, or a read/wait returned the end) — never a redundant "finished"; the disposed-owner race is contained.
|
||||
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's session. Injection is durable context for the next request, not a wake-up. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice; owner-disposal races are contained.
|
||||
|
||||
## Config
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `waitTimeoutMs` | `30000` | wait duration when `task_output` sets `wait` without `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | hard cap; larger model-supplied `timeout_ms` values are clamped |
|
||||
| `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
|
||||
| `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
|
||||
|
||||
A config whose default exceeds the cap fails loud at load.
|
||||
A default above the cap fails at load.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
**What the model sees**: Every request in this plugin's registration scope contains the background-task guidance below. Agent-scoped tool filtering can hide the control schemas without removing this independently registered section.
|
||||
**What the model sees**: Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section.
|
||||
|
||||
**Token effect**: Small fixed input cost per request while the plugin is active.
|
||||
**Token effect**: Small fixed input cost per request while active.
|
||||
|
||||
#### Background-task guidance
|
||||
|
||||
@@ -39,18 +39,18 @@ Track every background task id you start. You are notified in-session when a tas
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The model sees the generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this control surface is visible.
|
||||
**What the model sees**: The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible.
|
||||
|
||||
**Token effect**: Fixed schema cost on each request where the tools are visible.
|
||||
|
||||
### Task results and notices
|
||||
### Results and notices
|
||||
|
||||
**What the model sees**: Reads return a producer-owned output delta or `(no new output)`, followed by `[status: <status>]` with optional producer detail. Listing returns `(no background tasks)` or one `<id> [<kind>] <status> — <label>` line per visible task. Kill returns `requested cancellation of task <id>` or `task <id> had already finished [status: ...]`. An unreported owned completion injects exactly `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.`
|
||||
**What the model sees**: Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task <id>` or the existing terminal status. Unreported owned completion uses the notice above.
|
||||
|
||||
**Token effect**: Results and completion notices are retained in the parent session until compaction; stream reads consume their cursor and do not repeat prior output.
|
||||
**Token effect**: Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Completion notices do not wake idle agents** — they become durable context for the next request; callers needing an immediate result must use `task_output`.
|
||||
- **Stream reads are single-consumer** — this control surface exposes the task runtime's one consuming cursor rather than independent observers.
|
||||
- **Unowned tasks have no session fence** — deployments exposing background starts outside an agent must provide their own caller policy or avoid ownerless tasks.
|
||||
- **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`.
|
||||
- **Stream reads are single-consumer** — independent observers need another runtime API.
|
||||
- **Unowned tasks have no session fence** — external surfaces must supply caller policy or avoid them.
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* The model-facing background task control tools: `task_output`, `task_list`,
|
||||
* `task_kill`. Kind-agnostic — a background bash command and a background
|
||||
* subagent read, list, and die through the same three schemas — with every
|
||||
* task concern (ids, isolation, cursors, settlement) behind the `ctx.tasks`
|
||||
* registry (`@deepseek-ai/dsh-tasks`).
|
||||
*
|
||||
* This plugin IS the control surface: it calls `ctx.tasks.attachSurface()` on
|
||||
* load, which is what arms producers' `ctx.tasks.start()` (the runtime's
|
||||
* preflight refuses background work while no surface could collect or stop it).
|
||||
*
|
||||
* Completion notices: when a task settles, a short notice is injected into
|
||||
* the owning agent's session (`agent.inject()` — durable context for the NEXT
|
||||
* model request, not a wake-up). A task whose terminal state the model
|
||||
* already saw (`snapshot.reported` — an explicit kill, or a read/wait that
|
||||
* returned the end) is suppressed, so the model never gets a redundant
|
||||
* "finished" for work it just collected.
|
||||
*
|
||||
* Model-facing `task_output`, `task_list`, and `task_kill` tools over
|
||||
* `ctx.tasks`. Loading the plugin attaches the control surface required by
|
||||
* producers. It also injects unreported completions as durable context for the
|
||||
* owner's next request; notices do not wake idle agents.
|
||||
* @module @deepseek-ai/dsh-tool-tasks
|
||||
*/
|
||||
|
||||
@@ -30,7 +17,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
export const name = 'tool-tasks'
|
||||
export const inject = ['tools', 'tasks', 'systemPrompt']
|
||||
|
||||
/** 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
|
||||
@@ -44,12 +31,9 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Render a snapshot's status line — generic status plus the producer's
|
||||
* kind-specific detail: `[status: completed, exit code: 0]`,
|
||||
* `[status: failed, max-tokens]`, `[status: running]`. Exported for tests
|
||||
* and for producers that want a consistent line in their own results.
|
||||
* @param snapshot - the task state to render.
|
||||
* @returns the bracketed status line.
|
||||
* Render generic status with optional producer detail.
|
||||
* @param snapshot - task state to render.
|
||||
* @returns a bracketed status line.
|
||||
*/
|
||||
export function statusLine(snapshot: TaskSnapshot): string {
|
||||
return snapshot.detail !== undefined
|
||||
@@ -57,11 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string {
|
||||
: `[status: ${snapshot.status}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type/presence come from the SchemaSpec
|
||||
* validation; only the non-empty constraint, which the DSL cannot express,
|
||||
* is checked here.
|
||||
*/
|
||||
/** Validate the non-empty constraint that SchemaSpec cannot express. */
|
||||
function validateTaskId(value: string): TaskId {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)
|
||||
@@ -69,7 +49,7 @@ function validateTaskId(value: string): TaskId {
|
||||
return TaskId(value)
|
||||
}
|
||||
|
||||
/** Pending-state presentation shared by the three control tools (generic cards by design — a task read/kill is not a terminal). */
|
||||
/** Pending presentation shared by the three generic task controls. */
|
||||
function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: string): GenericCallView {
|
||||
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
|
||||
}
|
||||
@@ -81,24 +61,18 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
|
||||
}
|
||||
|
||||
// The registry's misconfiguration fence: producers can register background
|
||||
// work only while a surface capable of collecting/stopping it is attached.
|
||||
// Producers may start work only while a control surface is attached.
|
||||
ctx.tasks.attachSurface('tool-tasks')
|
||||
|
||||
// The cross-call HABIT the per-tool descriptions cannot carry. Order 106:
|
||||
// right after tool:bash (105), before deployment product sections.
|
||||
// Cross-call guidance follows the bash section and precedes product sections.
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:tasks',
|
||||
order: 106,
|
||||
text: 'Track 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.',
|
||||
})
|
||||
|
||||
// Background completion → inject a notice through the exact lifecycle owner.
|
||||
// Re-resolving by a reusable agent/session id could target a replacement
|
||||
// while the old owner's scope is still unwinding.
|
||||
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
||||
ctx.tasks.onTaskDone((snapshot, owner) => {
|
||||
// A reported terminal state was already surfaced by an explicit
|
||||
// read/wait/kill response — a notice would be a redundant "finished".
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
try {
|
||||
owner.inject(
|
||||
@@ -106,9 +80,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
{ source: { kind: 'plugin', plugin: 'tool-tasks' } },
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// The ONE expected failure: the agent was disposed between settlement
|
||||
// and this injection (inject throws `agent "<id>" is disposed`). That
|
||||
// race is benign — drop the notice. Anything else must surface.
|
||||
// Disposal may win the race after settlement; other injection failures surface.
|
||||
if (error instanceof Error && error.message.includes('is disposed')) return
|
||||
throw error
|
||||
}
|
||||
@@ -116,16 +88,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'task_output',
|
||||
description: 'Read output/status from a background task (started by a tool with `run_in_background`). '
|
||||
+ 'Stream tasks (bash) return only output produced since your previous task_output call; '
|
||||
+ 'final-output tasks (subagent) return the final answer once the task finishes. '
|
||||
+ 'Every response ends with a [status: ...] line. Non-blocking by default; '
|
||||
+ 'set `wait: true` to block until the task finishes (bounded by a capped timeout) when you are genuinely blocked on its result.',
|
||||
// Deliberately NO ToolDefinition.timeoutMs: the timeout-policy plugin
|
||||
// replaces a timed-out call with a structured TOOL_TIMEOUT failure, but a
|
||||
// timed-out wait here is a SUCCESS that reports [status: running] — the
|
||||
// task's state must reach the model either way, so the wait bounds its
|
||||
// own deadline (waitTimeoutMs/maxWaitTimeoutMs) via ctx.tasks.wait.
|
||||
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.',
|
||||
// A timed-out wait returns task state rather than a TOOL_TIMEOUT error, so
|
||||
// this tool owns its deadline instead of using ToolDefinition.timeoutMs.
|
||||
parameters: {
|
||||
task_id: { type: 'string', required: true, 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.' },
|
||||
@@ -149,8 +116,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
name: 'task_list',
|
||||
description: 'List your background tasks (running and finished) with their ids, kinds, and statuses.',
|
||||
parameters: {},
|
||||
// execute is synchronous (registry reads + string shaping) but the
|
||||
// ToolDefinition contract wants a Promise — hence resolve(), not async.
|
||||
execute(_args, exec) {
|
||||
const tasks = ctx.tasks.list(exec.agent)
|
||||
const text = tasks.length === 0
|
||||
@@ -172,8 +137,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const id = validateTaskId(args.task_id)
|
||||
const result = ctx.tasks.kill(id, exec.agent, args.reason)
|
||||
if (result === 'already-terminal') {
|
||||
// ctx.tasks.get, NOT .read: a read would consume a stream task's
|
||||
// pending delta just to describe the terminal state.
|
||||
// A snapshot describes terminal state without consuming pending output.
|
||||
const snapshot = ctx.tasks.get(id, exec.agent)
|
||||
return Promise.resolve([{ type: 'text', text: `task ${id} had already finished ${statusLine(snapshot)}` }])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user