From a08d129eef4c9b411e77a648d8760033f327b0b2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:03:51 -0700 Subject: [PATCH 1/2] Fail the turn instead of dispatching truncated tool calls --- src/plugins/path-escape-plugin.ts | 8 -- vendor/intx-inference/PATCHES.md | 32 +++++ vendor/intx-inference/src/harness.ts | 74 +++++++++-- .../src/providers/anthropic.test.ts | 117 ++++++++++++++++++ .../intx-inference/src/providers/anthropic.ts | 20 ++- vendor/intx-types/PATCHES.md | 20 +++ vendor/intx-types/src/runtime.ts | 6 +- 7 files changed, 258 insertions(+), 19 deletions(-) diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index 2e7b53a46..5a33d7f76 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -28,14 +28,6 @@ export function pathEscapePlugin( ): ToolPlugin { return { middleware: (next) => async (call, signal) => { - if ("_raw" in call.arguments) { - return { - callId: call.id, - content: - "Tool call arguments were malformed JSON (likely truncated). Retry with a smaller payload.", - isError: true, - }; - } let escaped: Record; try { escaped = escapeArgs( diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 1947c2602..73071b455 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -406,6 +406,37 @@ gap rather than carrying indefinitely. the cast can be deleted from the vendored file. **Re-carry:** clean three-way at `0205b07b`, zero conflicts. Low risk. +## inference-ts-cl-7783-truncated-tool-call + +End-of-stream finalization in `harness.ts` (`finalizeStreamTurn`) never +dispatches a tool call whose arguments do not parse as a normal call. The +prior code fell back to a `{ _raw: }` tool_call block, which +the reactor dispatched — executing a tool with truncated arguments when the +model was cut off by `max_tokens` (CL-7783: a truncated `Bash` +`rm -rf /tm…` fragment reached the executor). Now: when `stopReason` is +`max_tokens` and a tool call is still open, the turn fails retryably with a +message naming the tool and the truncated prefix, telling the model to retry +with a narrower scope; any other unparseable-args case fails retryably as +invalid JSON. Two supporting changes: `providers/anthropic.ts` parses +`stop_reason` out of `MessageDelta` (previously stripped by the schema) and +surfaces it on `inference.usage`, and `vendor/intx-types`' `InferenceUsageEvent` +gains the optional `stopReason` field both halves flow through. Guarded by the +CL-7783 regression suite in `providers/anthropic.test.ts`, which drives the +exact incident wire sequence and asserts no `tool_call` block reaches the +reactor. The OpenAI-compatible adapter was audited for the same path: it has +no adapter-local args fallback (the harness was the only dispatch site) but +still drops `finish_reason` on both paths, so OpenAI streams get the generic +invalid-JSON failure rather than the truncation-specific message. + +**Disposition:** Promotion candidate. Safety/correctness fix — prevents +executing tools with truncated arguments after a `max_tokens` cutoff. +**Removal path:** Upstream PR (a) surfacing `stop_reason`/`finish_reason` on +usage events and (b) failing the turn instead of dispatching unparseable tool +calls at end-of-stream finalization. **Re-carry:** localized to +`finalizeStreamTurn`, the `MessageDelta` schema, and one optional event +field; re-applies against upstream `harness.ts`/`anthropic.ts` unless the +finalization path is reworked. + --- ## Upstream promotion ledger @@ -428,6 +459,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`). | reactor-ts-after-checkpoint-director-only | Gate `afterCheckpoint` on `hasOverride` so auto-commits do not emit it | Alexander Guy | This ledger (#reactor-ts-after-checkpoint-director-only) | Next vendored sync | | sse-ts-max-line-length | Cap the unterminated SSE line buffer (`MAX_LINE_LENGTH`, 16 MiB) | Alexander Guy | This ledger (#sse-ts-max-line-length) | Next vendored sync | | state-ts-deep-freeze-turns-revision | Make `ReactorState.snapshot().turns` a lazy, revision-tracked getter | Alexander Guy | This ledger (#state-ts-deep-freeze-turns-revision) | Next vendored sync | +| inference-ts-cl-7783-truncated-tool-call | Surface `stop_reason`/`finish_reason` on usage events; fail the turn instead of dispatching unparseable tool calls at end-of-stream finalization | Alexander Guy | This ledger (#inference-ts-cl-7783-truncated-tool-call) | Next vendored sync | | google-genai-files-ts-body-init-cast | Widen `BodyInit` to accept Node's `Uint8Array` typing so the cast can be removed | Alexander Guy | This ledger (#google-genai-files-ts-body-init-cast) | Next vendored sync | Contact basis: identified from the read-only upstream clone diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index adeb0536c..545e58d55 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -316,6 +316,8 @@ async function* runSingleAttempt( // capture). Appended to the finalized turn after indexed blocks. const unindexedSafetyRatings: SafetyRatingBlock[] = []; let usageSeen: TokenUsage | null = null; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + let stopReason: string | undefined; // Tool call state: keyed by callId (or index for OpenAI). type ToolCallState = { @@ -1058,10 +1060,18 @@ async function* runSingleAttempt( // synthesizes its own descriptor cannot drift from the // call-start identity the rest of the harness commits to. usageSeen = mergeUsage(usageSeen, raw.data.usage); + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + if (raw.data.stopReason !== undefined) { + stopReason = raw.data.stopReason; + } yield { type: "inference.usage", seq: nextSeq(), - data: { usage: usageSeen, source: lastCycleSource }, + data: { + usage: usageSeen, + ...(stopReason === undefined ? {} : { stopReason }), + source: lastCycleSource, + }, }; break; } @@ -1110,18 +1120,68 @@ async function* runSingleAttempt( } // Finalize any open tool calls that never received an explicit end event. - const completedToolCalls: ContentBlock[] = []; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call: + // validate every open call before emitting any of them, and never + // dispatch a call whose arguments are incomplete or unparseable. A turn + // cut at max_tokens with calls still open is unambiguous truncation; + // anything else unparseable is still not a normal call. Both fail the + // turn retryably so the model can re-issue it with room to finish. + const finalizedToolCalls: { + tc: ToolCallState; + parsedArgs: Record; + }[] = []; for (const tc of openToolCalls.values()) { - let parsedArgs: Record; + if (stopReason === "max_tokens") { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: { + category: "retryable", + message: + `Tool call '${tc.name}' (${tc.callId}) was not executed: the provider ended the turn ` + + `at max_tokens while its arguments were still streaming (truncated input). ` + + `Retry the turn with a larger max_tokens budget or a smaller request so the full tool call fits.`, + }, + partial: snapshotPartial(partial), + }, + }; + return; + } + let parsed: unknown; try { const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer; - const parsed = JSON.parse(raw); - const validated = ParsedToolArgs(parsed); - parsedArgs = validated instanceof type.errors ? {} : validated; + parsed = JSON.parse(raw); } catch { - parsedArgs = { _raw: tc.argsBuffer }; + const tail = + tc.argsBuffer.length > 200 + ? `…${tc.argsBuffer.slice(-200)}` + : tc.argsBuffer; + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: { + category: "retryable", + message: + `Tool call '${tc.name}' (${tc.callId}) was not executed: its streamed arguments are not ` + + `valid JSON and cannot be dispatched as a normal call. Re-issue the turn; ` + + `partial argument text ends with: ${JSON.stringify(tail)}.`, + }, + partial: snapshotPartial(partial), + }, + }; + return; } + const validated = ParsedToolArgs(parsed); + finalizedToolCalls.push({ + tc, + parsedArgs: validated instanceof type.errors ? {} : validated, + }); + } + const completedToolCalls: ContentBlock[] = []; + for (const { tc, parsedArgs } of finalizedToolCalls) { completedToolCalls.push({ type: "tool_call", id: tc.callId, diff --git a/vendor/intx-inference/src/providers/anthropic.test.ts b/vendor/intx-inference/src/providers/anthropic.test.ts index 52cf812bf..99886c11a 100644 --- a/vendor/intx-inference/src/providers/anthropic.test.ts +++ b/vendor/intx-inference/src/providers/anthropic.test.ts @@ -1260,3 +1260,120 @@ describe("createAnthropicAdapter — streaming vs non-streaming parity", () => { expect(jdone?.data.usage).toEqual(sdone?.data.usage); }); }); + +describe("CL-7783 truncated tool_use", () => { + // The exact incident wire sequence: a tool_use block opens, one partial + // input_json_delta arrives, then message_delta reports stop_reason + // max_tokens and the stream stops — no content_block_stop ever closes + // the tool block, so its arguments are unparseable by construction. + const TRUNCATED_STREAM = sse([ + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_trunc", name: "Bash" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"command":"rm -rf /tm', + }, + }, + { + type: "message_delta", + delta: { stop_reason: "max_tokens" }, + usage: { output_tokens: 12 }, + }, + { type: "message_stop" }, + ]); + + function errorEvents(events: InferenceEvent[]) { + return events.filter( + (e): e is Extract => + e.type === "inference.error", + ); + } + + function usageEvents(events: InferenceEvent[]) { + return events.filter( + (e): e is Extract => + e.type === "inference.usage", + ); + } + + test("message_delta stop_reason surfaces on the usage event", async () => { + const { events } = await driveTurn(TRUNCATED_STREAM, "text/event-stream"); + const usage = usageEvents(events); + expect(usage.length).toBeGreaterThan(0); + expect(usage[usage.length - 1]?.data.stopReason).toBe("max_tokens"); + }); + + test("truncated call fails the turn retryably; no tool_call is dispatched", async () => { + const { turn, events } = await driveTurn( + TRUNCATED_STREAM, + "text/event-stream", + ); + expect(turn).toBeUndefined(); + expect(events.some((e) => e.type === "inference.done")).toBe(false); + expect( + events.some((e) => e.type === "inference.tool_call.end"), + ).toBe(false); + const errors = errorEvents(events); + expect(errors).toHaveLength(1); + expect(errors[0]?.data.error.category).toBe("retryable"); + expect(errors[0]?.data.error.message).toContain("max_tokens"); + expect(errors[0]?.data.error.message).toContain("Bash"); + expect(errors[0]?.data.error.message).toContain("not executed"); + }); + + test("unparseable args with a non-truncation stop reason still never dispatch", async () => { + const body = sse([ + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_bad", name: "Bash" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"command":', + }, + }, + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 12 }, + }, + { type: "message_stop" }, + ]); + const { turn, events } = await driveTurn(body, "text/event-stream"); + expect(turn).toBeUndefined(); + expect(events.some((e) => e.type === "inference.done")).toBe(false); + expect( + events.some((e) => e.type === "inference.tool_call.end"), + ).toBe(false); + const errors = errorEvents(events); + expect(errors).toHaveLength(1); + expect(errors[0]?.data.error.category).toBe("retryable"); + expect(errors[0]?.data.error.message).toContain("not valid JSON"); + }); + + test("non-streaming message surfaces top-level stop_reason on usage", async () => { + const body = JSON.stringify({ + type: "message", + role: "assistant", + model: "claude-test", + content: [{ type: "text", text: "Done." }], + stop_reason: "end_turn", + usage: { input_tokens: 5, output_tokens: 3 }, + }); + const { events } = await driveTurn(body, "application/json"); + expect(events.some((e) => e.type === "inference.error")).toBe(false); + const usage = usageEvents(events); + expect(usage).toHaveLength(1); + expect(usage[0]?.data.stopReason).toBe("end_turn"); + }); +}); diff --git a/vendor/intx-inference/src/providers/anthropic.ts b/vendor/intx-inference/src/providers/anthropic.ts index 411ca43b9..fe259ec11 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -554,6 +554,8 @@ const ContentBlockStop = type({ const MessageDelta = type({ type: "'message_delta'", + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + "delta?": { "stop_reason?": "string" }, "usage?": { "output_tokens?": "number" }, }); @@ -816,11 +818,17 @@ function parseResponse( cacheWrite: 0, thinking: 0, }; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + const stopReason = event.delta?.stop_reason; return [ { type: "inference.usage", seq, - data: { usage: inferenceUsage, source }, + data: { + usage: inferenceUsage, + ...(stopReason === undefined ? {} : { stopReason }), + source, + }, }, ]; } @@ -869,6 +877,8 @@ const NonStreamingUsage = type({ const NonStreamingMessage = type({ type: "'message'", content: "unknown[]", + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + "stop_reason?": "string", usage: NonStreamingUsage, }); @@ -1064,7 +1074,13 @@ function parseJSONResponse( events.push({ type: "inference.usage", seq, - data: { usage: toInferenceUsage(message.usage), source }, + data: { + usage: toInferenceUsage(message.usage), + ...(message.stop_reason === undefined + ? {} + : { stopReason: message.stop_reason }), + source, + }, }); return events; diff --git a/vendor/intx-types/PATCHES.md b/vendor/intx-types/PATCHES.md index e1243c670..103982210 100644 --- a/vendor/intx-types/PATCHES.md +++ b/vendor/intx-types/PATCHES.md @@ -17,3 +17,23 @@ seq 0. Companion to `store-ts-load-errors` in `@intx/storage-isogit` and **Disposition:** Promotion candidate. **Removal path:** Upstream PR adding `loadErrors` to `AuditStore`; then drop this entry and its marker. + +## types-ts-usage-stop-reason + +`src/runtime.ts` — The `inference.usage` variant of `InferenceEvent` +gains optional `data.stopReason: string`, populated by adapters that +observe a wire-level stop/finish reason (Anthropic `stop_reason`). +`inference.usage` is the only harness-level signal that records how a +turn ended; without the provider's stop reason the harness cannot +distinguish a complete turn from a truncation (`max_tokens` with a tool +call still open), which is the CL-7783 failure: truncated `tool_use` +input was dispatched as a well-formed call. Absent when the provider +surfaces none; consumers must treat a missing `stopReason` as "unknown", +never as "complete". + +**Disposition:** Promotion candidate (companion to the `intx-inference` +CL-7783 entry `inference-ts-cl-7783-truncated-tool-call` — ships out or +dies with it). Requires upstream to add a stop-reason field to the +usage event (or equivalent). **Removal path:** Upstream PR to +`@intx/types` carrying the field; drop the marker and this entry once +the re-sync pin includes it. diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts index cb9ab2647..2a796fa7e 100644 --- a/vendor/intx-types/src/runtime.ts +++ b/vendor/intx-types/src/runtime.ts @@ -1379,7 +1379,8 @@ export const InferenceEvent = type({ .or({ type: "'inference.usage'", seq: "number", - data: { usage: TokenUsage, source: LastCycleSource }, + // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason + data: { usage: TokenUsage, source: LastCycleSource, "stopReason?": "string" }, }) .or({ type: "'inference.done'", @@ -1643,7 +1644,8 @@ export type InferenceEvent = | { type: "inference.usage"; seq: number; - data: { usage: TokenUsage; source: LastCycleSource }; + // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason + data: { usage: TokenUsage; source: LastCycleSource; stopReason?: string }; } | { type: "inference.done"; From 980944c305df77b57678ccd7f8a7fa2da8b9c343 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:35:15 -0700 Subject: [PATCH 2/2] Forward Gemini finishReason to usage and correct truncation docs --- vendor/intx-inference/PATCHES.md | 20 ++++++-- vendor/intx-inference/src/harness.ts | 6 ++- .../src/providers/google-genai.test.ts | 46 +++++++++++++++++++ .../src/providers/google-genai.ts | 9 +++- vendor/intx-types/PATCHES.md | 3 +- 5 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 vendor/intx-inference/src/providers/google-genai.test.ts diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 73071b455..8ee373d1d 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -414,10 +414,15 @@ prior code fell back to a `{ _raw: }` tool_call block, which the reactor dispatched — executing a tool with truncated arguments when the model was cut off by `max_tokens` (CL-7783: a truncated `Bash` `rm -rf /tm…` fragment reached the executor). Now: when `stopReason` is -`max_tokens` and a tool call is still open, the turn fails retryably with a -message naming the tool and the truncated prefix, telling the model to retry -with a narrower scope; any other unparseable-args case fails retryably as -invalid JSON. Two supporting changes: `providers/anthropic.ts` parses +`max_tokens` and a tool call is still open, the turn fails with an +`inference.error` naming the tool and the truncated prefix, advising a +larger budget or a narrower scope for the model's next attempt; any other +unparseable-args case fails the same way as invalid JSON. Both errors carry +category `retryable`, but end-of-stream finalization always runs after the +attempt has committed visible output, so the harness commitment boundary +suppresses the mechanical retry — the failure is terminal for the turn, and +the message is guidance for the next attempt rather than a re-issued retry. +Two supporting changes: `providers/anthropic.ts` parses `stop_reason` out of `MessageDelta` (previously stripped by the schema) and surfaces it on `inference.usage`, and `vendor/intx-types`' `InferenceUsageEvent` gains the optional `stopReason` field both halves flow through. Guarded by the @@ -426,7 +431,12 @@ exact incident wire sequence and asserts no `tool_call` block reaches the reactor. The OpenAI-compatible adapter was audited for the same path: it has no adapter-local args fallback (the harness was the only dispatch site) but still drops `finish_reason` on both paths, so OpenAI streams get the generic -invalid-JSON failure rather than the truncation-specific message. +invalid-JSON failure rather than the truncation-specific message. The Gemini +adapter forwards its terminal `finishReason` onto `inference.usage` (same +spread idiom as Anthropic), but forwards the provider's raw spelling +(`MAX_TOKENS`), which the harness `max_tokens` comparison does not match — +so Gemini truncation still lands on the generic invalid-JSON failure until +the harness normalizes provider spellings. **Disposition:** Promotion candidate. Safety/correctness fix — prevents executing tools with truncated arguments after a `max_tokens` cutoff. diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index 545e58d55..84444dfe4 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -1124,8 +1124,10 @@ async function* runSingleAttempt( // validate every open call before emitting any of them, and never // dispatch a call whose arguments are incomplete or unparseable. A turn // cut at max_tokens with calls still open is unambiguous truncation; - // anything else unparseable is still not a normal call. Both fail the - // turn retryably so the model can re-issue it with room to finish. + // anything else unparseable is still not a normal call. Both yield an + // inference.error (category retryable) naming the call; post-commit the + // harness surfaces it terminally rather than mechanically retrying, so + // the message guides the model's next attempt. const finalizedToolCalls: { tc: ToolCallState; parsedArgs: Record; diff --git a/vendor/intx-inference/src/providers/google-genai.test.ts b/vendor/intx-inference/src/providers/google-genai.test.ts new file mode 100644 index 000000000..6bb76a54a --- /dev/null +++ b/vendor/intx-inference/src/providers/google-genai.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import type { LastCycleSource } from "@intx/types/runtime"; +import { createGoogleGenAIAdapter } from "./google-genai"; + +const TEST_SOURCE: LastCycleSource = { + sourceId: "test-google-genai", + provider: "google-genai", + model: "test-gemini-model", +}; + +describe("google-genai adapter — finishReason forwarding (CL-7783)", () => { + test("terminal finishReason surfaces on the usage event", () => { + const adapter = createGoogleGenAIAdapter(TEST_SOURCE); + const events = adapter.parseResponse( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: "partial" }], role: "model" }, + finishReason: "MAX_TOKENS", + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 }, + }), + ); + const usage = events.filter((e) => e.type === "inference.usage"); + expect(usage).toHaveLength(1); + expect(usage[0]?.data.stopReason).toBe("MAX_TOKENS"); + }); + + test("non-terminal event without finishReason emits no usage", () => { + const adapter = createGoogleGenAIAdapter(TEST_SOURCE); + const events = adapter.parseResponse( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: "partial" }], role: "model" }, + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 }, + }), + ); + expect(events.some((e) => e.type === "inference.usage")).toBe(false); + }); +}); diff --git a/vendor/intx-inference/src/providers/google-genai.ts b/vendor/intx-inference/src/providers/google-genai.ts index 3e4069a16..af61f329f 100644 --- a/vendor/intx-inference/src/providers/google-genai.ts +++ b/vendor/intx-inference/src/providers/google-genai.ts @@ -1461,7 +1461,14 @@ function parseResponse( out.push({ type: "inference.usage", seq, - data: { usage: tokenUsage, source }, + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + data: { + usage: tokenUsage, + ...(candidate.finishReason === undefined + ? {} + : { stopReason: candidate.finishReason }), + source, + }, }); // Terminal events seal the response. A still-pending diff --git a/vendor/intx-types/PATCHES.md b/vendor/intx-types/PATCHES.md index 103982210..9efb3616e 100644 --- a/vendor/intx-types/PATCHES.md +++ b/vendor/intx-types/PATCHES.md @@ -22,7 +22,8 @@ seq 0. Companion to `store-ts-load-errors` in `@intx/storage-isogit` and `src/runtime.ts` — The `inference.usage` variant of `InferenceEvent` gains optional `data.stopReason: string`, populated by adapters that -observe a wire-level stop/finish reason (Anthropic `stop_reason`). +observe a wire-level stop/finish reason (Anthropic `stop_reason`, Gemini +`finishReason`). `inference.usage` is the only harness-level signal that records how a turn ended; without the provider's stop reason the harness cannot distinguish a complete turn from a truncation (`max_tokens` with a tool