From 7887391afe8a0168459c69b63069a86bf7836ba1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:14:06 +0800 Subject: [PATCH] fix(tasks): capture result limits before policy --- ...06-20-generic-long-running-tool-runtime.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/tasks/tool-tasks/README.md | 2 +- packages/tasks/tool-tasks/src/index.ts | 18 +++++++------ .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 26 +++++++++++++++++++ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index f30b202f7d..8aaefe1e07 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -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 ` 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. +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 ` 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 resolves the caller-visible producer cap in an outer pre-execute listener before policy can deny or short-circuit dispatch, then applies it outside normalized dispatch and downstream post-execute policy so thrown hooks and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. ## Producer opt-in diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8582d70270..e52cc1fb59 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,7 +44,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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), [`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/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), [`tool-tasks`](../packages/tasks/tool-tasks) | | `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) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 06c3e52a62..ea3625093b 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -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 ` 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 ` 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 diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index cdcf328fec..fb40a2eeae 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -95,12 +95,11 @@ function boundSingleText(content: readonly ContentBlock[], maxBytes: number): Co }] } -function rememberOutputLimit( - limits: WeakMap, - 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() + 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 => { 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. diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 343c6338d4..b9e8832ea3 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -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' })