fix(tasks): bound task control failures

This commit is contained in:
Tianyi Cui
2026-07-23 01:56:10 +08:00
parent f8939daf0a
commit 9900550bdc
6 changed files with 111 additions and 6 deletions

View File

@@ -75,7 +75,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, or detail, so the minimum PTY cap still identifies the task to collect.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, or detail, so the minimum PTY cap still identifies the task to collect. The task surface remembers the producer cap before invoking read, wait, or cancellation hooks, then applies it outside normalized dispatch and downstream post-execute policy so thrown hooks and single-text replacements cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
## Producer opt-in

View File

@@ -1489,7 +1489,7 @@ export interface Config {
}
```
Source: [`packages/tasks/tool-tasks/src/index.ts:22`](../packages/tasks/tool-tasks/src/index.ts)
Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts)
## `@deepseek-ai/dsh-tool-web`

View File

@@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-pty`](../packages/pty/tool-pty), [`workspace-context`](../packages/context/workspace-context) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-pty`](../packages/pty/tool-pty), [`tool-tasks`](../packages/tasks/tool-tasks), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

View File

@@ -10,7 +10,7 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
All three use generic ACP cards: `read` for output and list, `execute` for kill.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, and detail. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`, and completion notices cap the complete UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background task <id>` and the `task_output` collection instruction before spending remaining bytes on its variable kind, label, status, and detail. An outer post-execute wrapper applies the producer's cap to normalized task-control failures and single-text policy replacements or blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded control-surface behavior.
## Completion notices

View File

@@ -8,9 +8,10 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
import { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -84,6 +85,24 @@ function fitCompletionNotice(snapshot: TaskSnapshot): string {
return `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`
}
function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined {
if (content.length !== 1) return undefined
const block = content[0]
if (block?.type !== 'text') return undefined
return [{
type: 'text',
text: fitWithSuffix(block.text, '', maxBytes, '\n[result truncated]'),
}]
}
function rememberOutputLimit(
limits: WeakMap<ToolExecution, number>,
exec: ToolExecution,
snapshot: TaskSnapshot,
): void {
if (snapshot.outputLimitBytes !== undefined) limits.set(exec, snapshot.outputLimitBytes)
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
@@ -104,6 +123,20 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(`tool-tasks: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`)
}
const outputLimits = new WeakMap<ToolExecution, number>()
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const decision = await next()
const maxBytes = outputLimits.get(exec)
outputLimits.delete(exec)
if (maxBytes === undefined) return decision
const content = decision.kind === 'block' ? decision.feedback : decision.content ?? result.content
const bounded = boundSingleText(content, maxBytes)
if (bounded === undefined) return decision
return decision.kind === 'block'
? { ...decision, feedback: bounded }
: { ...decision, content: bounded }
}, { prepend: true })
// Producers may start work only while a control surface is attached.
ctx.tasks.attachSurface('tool-tasks')
@@ -146,6 +179,7 @@ export function apply(ctx: Context, config: Config): void {
},
async execute(args, exec) {
const id = validateTaskId(args.task_id)
rememberOutputLimit(outputLimits, exec, ctx.tasks.get(id, exec.agent))
if (args.wait === true) {
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
await ctx.tasks.wait(id, timeout, exec.agent, exec.signal)
@@ -190,6 +224,7 @@ export function apply(ctx: Context, config: Config): void {
execute(args, exec) {
const id = validateTaskId(args.task_id)
const snapshot = ctx.tasks.get(id, exec.agent)
rememberOutputLimit(outputLimits, exec, snapshot)
const result = ctx.tasks.kill(id, exec.agent, args.reason)
if (result === 'already-finished') {
// A snapshot describes terminal state without consuming pending output.

View File

@@ -6,7 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import TaskService from '@deepseek-ai/dsh-tasks'
import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks'
import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
@@ -149,6 +149,19 @@ describe('task_output', () => {
expect(output).toContain('[status: running]')
})
it('applies a producer limit to a normalized read failure', async () => {
const { ctx } = await setup()
ctx.tasks.start(producer({
outputLimitBytes: 64,
readOutput: () => { throw new Error('read failed: '.repeat(100)) },
}).spec)
const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
expect(result.isError).toBe(true)
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
expect(text(result)).toContain('[result truncated]')
})
it('wait: true blocks until settlement and reports the terminal state', async () => {
const { ctx } = await setup()
const p = producer({ kind: 'subagent', label: 'research' })
@@ -223,6 +236,63 @@ describe('task_kill', () => {
expect(p.cancels).toEqual([undefined])
})
it('applies the producer output limit to a normalized cancellation failure', async () => {
const { ctx } = await setup()
ctx.tasks.start(producer({
outputLimitBytes: 64,
cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
}).spec)
const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
expect(result.isError).toBe(true)
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
expect(text(result)).toContain('[result truncated]')
expect(ctx.tasks.get(TaskId('bash-1'))).toMatchObject({ status: 'running', reported: false })
})
it('bounds single-text post policy while preserving structured policy results', async () => {
const { ctx } = await setup()
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name !== 'task_kill') return next()
const reason = (exec.arguments as { reason?: unknown }).reason
if (reason === 'replace') {
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'r'.repeat(1_000) }] })
}
if (reason === 'block') {
return Promise.resolve({ kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] })
}
if (reason === 'multi') {
return Promise.resolve({
kind: 'block',
feedback: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
})
}
if (reason === 'reasoning') {
return Promise.resolve({ kind: 'block', feedback: [{ type: 'reasoning', text: 'policy detail' }] })
}
return next()
})
for (let index = 0; index < 4; index += 1) {
ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
}
const replaced = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'replace' })
expect(replaced.isError).toBe(false)
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
expect(text(replaced)).toContain('[result truncated]')
const blocked = await call(ctx, 'task_kill', { task_id: 'bash-2', reason: 'block' })
expect(blocked.isError).toBe(true)
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
expect(text(blocked)).toContain('[result truncated]')
const multi = await call(ctx, 'task_kill', { task_id: 'bash-3', reason: 'multi' })
expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
const reasoning = await call(ctx, 'task_kill', { task_id: 'bash-4', reason: 'reasoning' })
expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
})
it('reports an already-finished task without consuming its pending delta', async () => {
const { ctx } = await setup()
let delta = 'unread tail'