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:
Tianyi Cui
2026-07-15 21:08:58 +08:00
parent 306b79fa2c
commit 8bb8ac8b3c
38 changed files with 548 additions and 1156 deletions
+20 -56
View File
@@ -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)}` }])
}