fix(tasks): bound task control failures
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user