From 94adb60e1a2175c54941de35353bebfc431d8334 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:15:15 +0800 Subject: [PATCH] fix(tools): preserve bounded fallback guidance --- ...06-20-generic-long-running-tool-runtime.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...19-cooperative-tool-cancellation.i18n.yaml | 4 +- ...026-07-19-cooperative-tool-cancellation.md | 2 +- ...-07-19-cooperative-tool-cancellation.zh.md | 2 +- .../feature/2026-06-30-interception-seams.md | 4 +- docs/core-data-structures/tools.md | 2 +- docs/tool-execution-pipeline.md | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 19 +++++-- packages/core/tools/tests/tools.spec.ts | 56 +++++++++++++++++++ packages/tasks/tool-tasks/README.md | 2 +- packages/tasks/tool-tasks/src/index.ts | 13 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 47 ++++++++++++++++ scripts/gen-doc-graphs.ts | 4 +- 15 files changed, 144 insertions(+), 21 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 83b5381818..a5fe44f12e 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 resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results 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, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task surface resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, 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/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 82adea706a..bc27d37268 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -218,7 +218,7 @@ A fresh registry-assigned Symbol provides collision-free execution identity with Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks. -After post-execute or outer pipeline normalization, the registry invokes the call's snapshotted optional `ToolDefinition.finalizeContent` callback, then materializes and freezes the accepted final result once. The callback may replace only content, so structured error identity, contexts, and metadata remain registry-owned even when a tool enforces a last-mile result bound. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are contained individually. An outer pipeline failure is normalized before final content, so observers can discard staged work against the same authoritative boundary. +After post-execute or outer pipeline normalization, the registry losslessly snapshots the candidate result, converting a snapshot failure into an ordinary error, invokes the call's snapshotted optional `ToolDefinition.finalizeContent` callback, then materializes and freezes the accepted final result once. The callback may replace only content, so structured error identity, contexts, and metadata remain registry-owned even when a tool enforces a last-mile result bound. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are contained individually. An outer pipeline or candidate-snapshot failure is normalized before final content, so observers can discard staged work against the same authoritative boundary. ### The assembly waterfall owns the final model-visible composition diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 5d517611af..77e3b8c14a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-cooperative-tool-cancellation.md: e86c087de53fe742436bc01571394657a0a5c9ac -2026-07-19-cooperative-tool-cancellation.zh.md: 91b91b3894dbdc56d57f4819ff4c567305ecfc9d +2026-07-19-cooperative-tool-cancellation.md: be237f6ca9475699bb4af76896772a1a7409033d +2026-07-19-cooperative-tool-cancellation.zh.md: 9ad212c2073063ccb0c838c08ab8f89c9285b26b diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index e86c087de5..be237f6ca9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -36,7 +36,7 @@ An around-dispatch wrapper may replace `exec.signal` for its delegated lifetime ### Pre-aborted entry short-circuits after materialization -The registry first creates the call token, snapshots the visible definition's optional final-content callback, and losslessly snapshots and freezes the arguments. A materialization failure wins even when the caller signal is already aborted. After successful materialization, a pre-aborted signal skips `tools/pre-execute`, approval, `tools/execute`, `tools/post-execute`, and the tool body, then passes `ABORTED_BEFORE_DISPATCH` through that content-only callback before publishing exactly one frozen authoritative `tools/result`. +The registry first creates the call token, snapshots the visible definition's optional final-content callback, and losslessly snapshots and freezes the arguments. An argument-materialization failure wins even when the caller signal is already aborted. Before final content, the registry also losslessly snapshots the candidate result and converts a result-snapshot failure into an ordinary error, so the callback can still enforce its content invariant. After successful argument materialization, a pre-aborted signal skips `tools/pre-execute`, approval, `tools/execute`, `tools/post-execute`, and the tool body, then passes `ABORTED_BEFORE_DISPATCH` through that content-only callback before publishing exactly one frozen authoritative `tools/result`. ### Started work still reaches quiescence diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 91b91b3894..9ad212c207 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -36,7 +36,7 @@ Status: implemented ### 进入时已中止会在物化后短路 -注册表先创建调用 token,对可见工具定义的可选 `finalizeContent` callback 做快照,并对参数进行无损快照和冻结。即使调用方信号已经中止,参数物化失败仍优先返回。物化成功后,进入时已中止的信号会跳过 `tools/pre-execute`、审批、`tools/execute`、`tools/post-execute` 和工具主体,然后先由该仅处理内容的 callback 处理 `ABORTED_BEFORE_DISPATCH`,再发布且只发布一次冻结的权威 `tools/result`。 +注册表先创建调用 token,对可见工具定义的可选 `finalizeContent` callback 做快照,并对参数进行无损快照和冻结。即使调用方信号已经中止,参数物化失败仍优先返回。在最终内容处理之前,注册表还会对候选结果进行无损快照,并把结果快照失败转换为普通错误,从而使该 callback 仍能保证其内容不变量成立。参数物化成功后,进入时已中止的信号会跳过 `tools/pre-execute`、审批、`tools/execute`、`tools/post-execute` 和工具主体,然后先由该仅处理内容的 callback 处理 `ABORTED_BEFORE_DISPATCH`,再发布且只发布一次冻结的权威 `tools/result`。 ### 已启动工作仍必须完全停稳 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 0b2f7eafc8..22c8d7847d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -26,10 +26,10 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. - **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. - **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported cross-tool transform channel. -- **`ToolDefinition.finalizeContent`** is an optional synchronous, total, content-only boundary snapshotted with the visible definition at call creation. It runs exactly once after the registry has normalized the final outcome, including pre-, around-, or post-listener failures that bypass later waterfalls. It may replace `content` or preserve it with `undefined`, but cannot rewrite `isError`, structured error identity, contexts, or presentation metadata. This is where a tool enforces its own last-mile content invariant without converting policy failures into weaker block decisions. +- **`ToolDefinition.finalizeContent`** is an optional synchronous, total, content-only boundary snapshotted with the visible definition at call creation. It runs exactly once after the registry has normalized and losslessly snapshotted the candidate outcome, including pre-, around-, or post-listener failures that bypass later waterfalls and errors discovered while snapshotting another result field. It may replace `content` or preserve it with `undefined`, but cannot rewrite `isError`, structured error identity, contexts, or presentation metadata. This is where a tool enforces its own last-mile content invariant without converting policy failures into weaker block decisions. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. -Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool; definition-owned final content invariants also cover outer pipeline failures; and a final observer sees exactly what the caller receives and the session log can persist. +Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool; definition-owned final content invariants also cover outer pipeline and candidate-snapshot failures; and a final observer sees exactly what the caller receives and the session log can persist. **`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 3174cff8b8..9ba0b07100 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -291,7 +291,7 @@ interface ToolExecutionResult { The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity. -The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append. +Before final content, the registry losslessly snapshots the candidate result; a failure in content, structured error, additional context, or presentation metadata becomes a JSON-safe `isError` result that still reaches `finalizeContent`. The registry then materializes and freezes the final accepted result immediately before `tools/result`, so the observed live outcome is safe for the later durable `tool/result` append. Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index a6646e6587..6904c31dd2 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -19,7 +19,7 @@ flowchart TD fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] - normalized["Registry outer normalization
pipeline throws become isError"] + normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] context["Active-batch additionalContexts FIFO
context/message after recorded tool results"] @@ -57,6 +57,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The visible definition's snapshotted `finalizeContent` callback then enforces a synchronous content-only invariant across ordinary decisions and normalized pipeline failures before `tools/result` observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 07b9dde1af..1517bcfacf 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,7 +15,7 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized. Disposed with the calling fiber. +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while losslessly snapshotting another result field. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index c434541b62..9577c8f858 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1057,9 +1057,15 @@ export class ToolRegistry extends Service { * @internal */ private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + let snapshottedResult: ToolExecutionResult + try { + snapshottedResult = this.snapshotFinalResult(result) + } catch (error: unknown) { + snapshottedResult = toolErrorResult(error) + } let finalResult: ToolExecutionResult try { - finalResult = this.materializeFinalResult(this.applyFinalContent(exec, result)) + finalResult = this.materializeFinalResult(this.applyFinalContent(exec, snapshottedResult)) } catch (error: unknown) { finalResult = this.materializeFinalResult(toolErrorResult(error)) } @@ -1187,13 +1193,18 @@ export class ToolRegistry extends Service { } } - /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ - private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { + /** Validate and detach one candidate outcome before tool-owned final content. */ + private snapshotFinalResult(result: ToolExecutionResult): ToolExecutionResult { const detached = snapshotJsonValue(result) if (detached === undefined) { throw new TypeError('tool result must be losslessly JSON-serializable') } - return deepFreeze(detached) + return detached + } + + /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ + private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { + return deepFreeze(this.snapshotFinalResult(result)) } } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index bddefaba41..ad2c0dca8c 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -140,6 +140,62 @@ describe('ToolRegistry', () => { expect(observedError).toBe(true) }) + it('finalizes errors discovered while snapshotting non-content result fields', async () => { + const ctx = await setup() + let finalizeCalls = 0 + ctx.tools.register({ + ...echoTool, + name: 'throwing-meta', + finalizeContent(_exec, result) { + finalizeCalls += 1 + const block = result.content[0] + if (block?.type !== 'text') return undefined + return [{ type: 'text', text: block.text.slice(0, 32) }] + }, + async execute() { + const meta = {} + Object.defineProperty(meta, 'value', { + enumerable: true, + get() { throw new Error('snapshot failed: '.repeat(100)) }, + }) + return { content: [{ type: 'text', text: 'body' }], meta } + }, + }) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('throwing-meta'), name: 'throwing-meta', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: snapshot failed: snapshot' }]) + expect(finalizeCalls).toBe(1) + }) + + it('normalizes a throwing final content callback without invoking it again', async () => { + const ctx = await setup() + let finalizeCalls = 0 + ctx.tools.register({ + ...echoTool, + name: 'throwing-finalizer', + finalizeContent() { + finalizeCalls += 1 + throw new Error('finalizer violated its total contract') + }, + }) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {}, + }) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: finalizer violated its total contract' }], + isError: true, + }) + expect(finalizeCalls).toBe(1) + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 4072a84205..2065ef8a08 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. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline 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. +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, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible task before policy, and each task-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline 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 40e6aaf7cb..d258299b40 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -81,8 +81,17 @@ function fitCompletionNotice(snapshot: TaskSnapshot): string { const omitted = '\n[notice truncated]' const fixed = `${prefix}${omitted}${action}` const fixedBytes = encoder.encode(fixed).byteLength - if (fixedBytes >= maxBytes) return retainHead(fixed, maxBytes) - return `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}` + if (fixedBytes <= maxBytes) { + return fixedBytes === maxBytes + ? fixed + : `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}` + } + const compact = `${prefix}${action}` + const compactBytes = encoder.encode(compact).byteLength + if (compactBytes <= maxBytes) return compact + const actionBytes = encoder.encode(action).byteLength + if (actionBytes >= maxBytes) return retainTail(action, maxBytes) + return `${retainHead(prefix, maxBytes - actionBytes)}${action}` } function boundSingleText(content: readonly ContentBlock[], maxBytes: number): ContentBlock[] | undefined { diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index eadd4eea11..dc27e16fe8 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -424,6 +424,53 @@ describe('completion notices', () => { expect(notice).toContain('[notice truncated]\nDone; task_output.') }) + it('keeps the complete PTY task id and collection action at the minimum PTY limit', async () => { + const { ctx } = await setup() + for (let index = 0; index < 99; index += 1) { + const prior = producer({ kind: 'pty-send' }) + ctx.tasks.start(prior.spec) + prior.settle({ status: 'completed' }) + } + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', inject) + const target = producer({ + owner, + kind: 'pty-send', + label: 'x'.repeat(1_000), + outputLimitBytes: 64, + }) + ctx.tasks.start(target.spec) + + target.settle({ status: 'completed', detail: 'd'.repeat(1_000) }) + await tick() + + const content = inject.mock.calls[0]?.[0] as Array<{ type: string; text?: string }> | undefined + const notice = content?.[0]?.text ?? '' + expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64) + expect(notice).toBe('background task pty-send-100\nDone; task_output.') + }) + + it('reserves the collection-action tail when a producer supplies a smaller budget', async () => { + const { ctx } = await setup() + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', inject) + const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) + const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) + ctx.tasks.start(tiny.spec) + ctx.tasks.start(short.spec) + + tiny.settle({ status: 'completed' }) + short.settle({ status: 'completed' }) + await tick() + + const tinyNotice = (inject.mock.calls[0]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? '' + const shortNotice = (inject.mock.calls[1]?.[0] as Array<{ text?: string }> | undefined)?.[0]?.text ?? '' + expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8) + expect(tinyNotice).toBe('_output.') + expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32) + expect(shortNotice).toBe('background ta\nDone; task_output.') + }) + it('suppresses the notice for a task the model already killed', async () => { const { ctx } = await setup() const inject = vi.fn() diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index b01a1514d5..2383710b3b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -987,7 +987,7 @@ function renderToolPipeline(): string { ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, - ' normalized["Registry outer normalization
pipeline throws become isError"]', + ' normalized["Registry outer normalization
pipeline/result snapshot throws become isError"]', ' finalize["ToolDefinition.finalizeContent
last content-only invariant"]', ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, ' context["Active-batch additionalContexts FIFO
context/message after recorded tool results"]', @@ -1025,7 +1025,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The visible definition\'s snapshotted `finalizeContent` callback then enforces a synchronous content-only invariant across ordinary decisions and normalized pipeline failures before `tools/result` observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n')