fix(tasks): capture result limits before policy
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 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.
|
||||
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 pre/post-execute pair captures the caller-visible task before policy and applies its producer cap to single-text denials, around-dispatch short-circuits, normalized task-control failures, replacements, and 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
|
||||
|
||||
|
||||
@@ -95,12 +95,11 @@ function boundSingleText(content: readonly ContentBlock[], maxBytes: number): Co
|
||||
}]
|
||||
}
|
||||
|
||||
function rememberOutputLimit(
|
||||
limits: WeakMap<ToolExecution, number>,
|
||||
exec: ToolExecution,
|
||||
snapshot: TaskSnapshot,
|
||||
): void {
|
||||
if (snapshot.outputLimitBytes !== undefined) limits.set(exec, snapshot.outputLimitBytes)
|
||||
function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefined {
|
||||
if (exec.name !== 'task_output' && exec.name !== 'task_kill') return undefined
|
||||
const taskId = (exec.arguments as { task_id?: unknown } | null | undefined)?.task_id
|
||||
if (typeof taskId !== 'string' || taskId.length === 0) return undefined
|
||||
return ctx.tasks.list(exec.agent).find(snapshot => snapshot.id === taskId)?.outputLimitBytes
|
||||
}
|
||||
|
||||
/** Validate the non-empty constraint that SchemaSpec cannot express. */
|
||||
@@ -124,6 +123,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
|
||||
const outputLimits = new WeakMap<ToolExecution, number>()
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
const maxBytes = visibleOutputLimit(ctx, exec)
|
||||
if (maxBytes !== undefined) outputLimits.set(exec, maxBytes)
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
const decision = await next()
|
||||
const maxBytes = outputLimits.get(exec)
|
||||
@@ -179,7 +183,6 @@ 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)
|
||||
@@ -224,7 +227,6 @@ 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.
|
||||
|
||||
@@ -162,6 +162,32 @@ describe('task_output', () => {
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
})
|
||||
|
||||
it('captures producer limits before pre- and around-execute policy', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
const taskId = (exec.arguments as { task_id?: unknown }).task_id
|
||||
return taskId === 'bash-1' ? { kind: 'deny', reason: 'd'.repeat(1_000) } : next()
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const taskId = (exec.arguments as { task_id?: unknown }).task_id
|
||||
return taskId === 'bash-2'
|
||||
? { content: [{ type: 'text', text: 'a'.repeat(1_000) }], isError: false }
|
||||
: next()
|
||||
})
|
||||
|
||||
const denied = await call(ctx, 'task_output', { task_id: 'bash-1' })
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
|
||||
expect(text(denied)).toContain('[result truncated]')
|
||||
|
||||
const shortCircuited = await call(ctx, 'task_output', { task_id: 'bash-2' })
|
||||
expect(shortCircuited.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
|
||||
expect(text(shortCircuited)).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' })
|
||||
|
||||
Reference in New Issue
Block a user