Skip to content

Commit 9334eed

Browse files
Fail the turn instead of dispatching truncated tool calls (#938)
* Fail the turn instead of dispatching truncated tool calls * Forward Gemini finishReason to usage and correct truncation docs
1 parent a619acb commit 9334eed

9 files changed

Lines changed: 325 additions & 20 deletions

File tree

src/plugins/path-escape-plugin.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ export function pathEscapePlugin(
2828
): ToolPlugin {
2929
return {
3030
middleware: (next) => async (call, signal) => {
31-
if ("_raw" in call.arguments) {
32-
return {
33-
callId: call.id,
34-
content:
35-
"Tool call arguments were malformed JSON (likely truncated). Retry with a smaller payload.",
36-
isError: true,
37-
};
38-
}
3931
let escaped: Record<string, unknown>;
4032
try {
4133
escaped = escapeArgs(

vendor/intx-inference/PATCHES.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,47 @@ gap rather than carrying indefinitely.
406406
the cast can be deleted from the vendored file.
407407
**Re-carry:** clean three-way at `0205b07b`, zero conflicts. Low risk.
408408

409+
## inference-ts-cl-7783-truncated-tool-call
410+
411+
End-of-stream finalization in `harness.ts` (`finalizeStreamTurn`) never
412+
dispatches a tool call whose arguments do not parse as a normal call. The
413+
prior code fell back to a `{ _raw: <partial JSON> }` tool_call block, which
414+
the reactor dispatched — executing a tool with truncated arguments when the
415+
model was cut off by `max_tokens` (CL-7783: a truncated `Bash`
416+
`rm -rf /tm…` fragment reached the executor). Now: when `stopReason` is
417+
`max_tokens` and a tool call is still open, the turn fails with an
418+
`inference.error` naming the tool and the truncated prefix, advising a
419+
larger budget or a narrower scope for the model's next attempt; any other
420+
unparseable-args case fails the same way as invalid JSON. Both errors carry
421+
category `retryable`, but end-of-stream finalization always runs after the
422+
attempt has committed visible output, so the harness commitment boundary
423+
suppresses the mechanical retry — the failure is terminal for the turn, and
424+
the message is guidance for the next attempt rather than a re-issued retry.
425+
Two supporting changes: `providers/anthropic.ts` parses
426+
`stop_reason` out of `MessageDelta` (previously stripped by the schema) and
427+
surfaces it on `inference.usage`, and `vendor/intx-types`' `InferenceUsageEvent`
428+
gains the optional `stopReason` field both halves flow through. Guarded by the
429+
CL-7783 regression suite in `providers/anthropic.test.ts`, which drives the
430+
exact incident wire sequence and asserts no `tool_call` block reaches the
431+
reactor. The OpenAI-compatible adapter was audited for the same path: it has
432+
no adapter-local args fallback (the harness was the only dispatch site) but
433+
still drops `finish_reason` on both paths, so OpenAI streams get the generic
434+
invalid-JSON failure rather than the truncation-specific message. The Gemini
435+
adapter forwards its terminal `finishReason` onto `inference.usage` (same
436+
spread idiom as Anthropic), but forwards the provider's raw spelling
437+
(`MAX_TOKENS`), which the harness `max_tokens` comparison does not match —
438+
so Gemini truncation still lands on the generic invalid-JSON failure until
439+
the harness normalizes provider spellings.
440+
441+
**Disposition:** Promotion candidate. Safety/correctness fix — prevents
442+
executing tools with truncated arguments after a `max_tokens` cutoff.
443+
**Removal path:** Upstream PR (a) surfacing `stop_reason`/`finish_reason` on
444+
usage events and (b) failing the turn instead of dispatching unparseable tool
445+
calls at end-of-stream finalization. **Re-carry:** localized to
446+
`finalizeStreamTurn`, the `MessageDelta` schema, and one optional event
447+
field; re-applies against upstream `harness.ts`/`anthropic.ts` unless the
448+
finalization path is reworked.
449+
409450
---
410451

411452
## Upstream promotion ledger
@@ -428,6 +469,7 @@ revisit point is the next vendored sync (see `docs/VENDORING.md`).
428469
| reactor-ts-after-checkpoint-director-only | Gate `afterCheckpoint` on `hasOverride` so auto-commits do not emit it | Alexander Guy <alexander.guy@pm.me> | This ledger (#reactor-ts-after-checkpoint-director-only) | Next vendored sync |
429470
| sse-ts-max-line-length | Cap the unterminated SSE line buffer (`MAX_LINE_LENGTH`, 16 MiB) | Alexander Guy <alexander.guy@pm.me> | This ledger (#sse-ts-max-line-length) | Next vendored sync |
430471
| state-ts-deep-freeze-turns-revision | Make `ReactorState.snapshot().turns` a lazy, revision-tracked getter | Alexander Guy <alexander.guy@pm.me> | This ledger (#state-ts-deep-freeze-turns-revision) | Next vendored sync |
472+
| 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 <alexander.guy@pm.me> | This ledger (#inference-ts-cl-7783-truncated-tool-call) | Next vendored sync |
431473
| google-genai-files-ts-body-init-cast | Widen `BodyInit` to accept Node's `Uint8Array` typing so the cast can be removed | Alexander Guy <alexander.guy@pm.me> | This ledger (#google-genai-files-ts-body-init-cast) | Next vendored sync |
432474

433475
Contact basis: identified from the read-only upstream clone

vendor/intx-inference/src/harness.ts

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ async function* runSingleAttempt(
316316
// capture). Appended to the finalized turn after indexed blocks.
317317
const unindexedSafetyRatings: SafetyRatingBlock[] = [];
318318
let usageSeen: TokenUsage | null = null;
319+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call
320+
let stopReason: string | undefined;
319321

320322
// Tool call state: keyed by callId (or index for OpenAI).
321323
type ToolCallState = {
@@ -1058,10 +1060,18 @@ async function* runSingleAttempt(
10581060
// synthesizes its own descriptor cannot drift from the
10591061
// call-start identity the rest of the harness commits to.
10601062
usageSeen = mergeUsage(usageSeen, raw.data.usage);
1063+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call
1064+
if (raw.data.stopReason !== undefined) {
1065+
stopReason = raw.data.stopReason;
1066+
}
10611067
yield {
10621068
type: "inference.usage",
10631069
seq: nextSeq(),
1064-
data: { usage: usageSeen, source: lastCycleSource },
1070+
data: {
1071+
usage: usageSeen,
1072+
...(stopReason === undefined ? {} : { stopReason }),
1073+
source: lastCycleSource,
1074+
},
10651075
};
10661076
break;
10671077
}
@@ -1110,18 +1120,70 @@ async function* runSingleAttempt(
11101120
}
11111121

11121122
// Finalize any open tool calls that never received an explicit end event.
1113-
const completedToolCalls: ContentBlock[] = [];
1123+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call:
1124+
// validate every open call before emitting any of them, and never
1125+
// dispatch a call whose arguments are incomplete or unparseable. A turn
1126+
// cut at max_tokens with calls still open is unambiguous truncation;
1127+
// anything else unparseable is still not a normal call. Both yield an
1128+
// inference.error (category retryable) naming the call; post-commit the
1129+
// harness surfaces it terminally rather than mechanically retrying, so
1130+
// the message guides the model's next attempt.
1131+
const finalizedToolCalls: {
1132+
tc: ToolCallState;
1133+
parsedArgs: Record<string, unknown>;
1134+
}[] = [];
11141135
for (const tc of openToolCalls.values()) {
1115-
let parsedArgs: Record<string, unknown>;
1136+
if (stopReason === "max_tokens") {
1137+
yield {
1138+
type: "inference.error",
1139+
seq: nextSeq(),
1140+
data: {
1141+
error: {
1142+
category: "retryable",
1143+
message:
1144+
`Tool call '${tc.name}' (${tc.callId}) was not executed: the provider ended the turn ` +
1145+
`at max_tokens while its arguments were still streaming (truncated input). ` +
1146+
`Retry the turn with a larger max_tokens budget or a smaller request so the full tool call fits.`,
1147+
},
1148+
partial: snapshotPartial(partial),
1149+
},
1150+
};
1151+
return;
1152+
}
1153+
let parsed: unknown;
11161154
try {
11171155
const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer;
1118-
const parsed = JSON.parse(raw);
1119-
const validated = ParsedToolArgs(parsed);
1120-
parsedArgs = validated instanceof type.errors ? {} : validated;
1156+
parsed = JSON.parse(raw);
11211157
} catch {
1122-
parsedArgs = { _raw: tc.argsBuffer };
1158+
const tail =
1159+
tc.argsBuffer.length > 200
1160+
? `…${tc.argsBuffer.slice(-200)}`
1161+
: tc.argsBuffer;
1162+
yield {
1163+
type: "inference.error",
1164+
seq: nextSeq(),
1165+
data: {
1166+
error: {
1167+
category: "retryable",
1168+
message:
1169+
`Tool call '${tc.name}' (${tc.callId}) was not executed: its streamed arguments are not ` +
1170+
`valid JSON and cannot be dispatched as a normal call. Re-issue the turn; ` +
1171+
`partial argument text ends with: ${JSON.stringify(tail)}.`,
1172+
},
1173+
partial: snapshotPartial(partial),
1174+
},
1175+
};
1176+
return;
11231177
}
1178+
const validated = ParsedToolArgs(parsed);
1179+
finalizedToolCalls.push({
1180+
tc,
1181+
parsedArgs: validated instanceof type.errors ? {} : validated,
1182+
});
1183+
}
11241184

1185+
const completedToolCalls: ContentBlock[] = [];
1186+
for (const { tc, parsedArgs } of finalizedToolCalls) {
11251187
completedToolCalls.push({
11261188
type: "tool_call",
11271189
id: tc.callId,

vendor/intx-inference/src/providers/anthropic.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,3 +1260,120 @@ describe("createAnthropicAdapter — streaming vs non-streaming parity", () => {
12601260
expect(jdone?.data.usage).toEqual(sdone?.data.usage);
12611261
});
12621262
});
1263+
1264+
describe("CL-7783 truncated tool_use", () => {
1265+
// The exact incident wire sequence: a tool_use block opens, one partial
1266+
// input_json_delta arrives, then message_delta reports stop_reason
1267+
// max_tokens and the stream stops — no content_block_stop ever closes
1268+
// the tool block, so its arguments are unparseable by construction.
1269+
const TRUNCATED_STREAM = sse([
1270+
{
1271+
type: "content_block_start",
1272+
index: 0,
1273+
content_block: { type: "tool_use", id: "toolu_trunc", name: "Bash" },
1274+
},
1275+
{
1276+
type: "content_block_delta",
1277+
index: 0,
1278+
delta: {
1279+
type: "input_json_delta",
1280+
partial_json: '{"command":"rm -rf /tm',
1281+
},
1282+
},
1283+
{
1284+
type: "message_delta",
1285+
delta: { stop_reason: "max_tokens" },
1286+
usage: { output_tokens: 12 },
1287+
},
1288+
{ type: "message_stop" },
1289+
]);
1290+
1291+
function errorEvents(events: InferenceEvent[]) {
1292+
return events.filter(
1293+
(e): e is Extract<InferenceEvent, { type: "inference.error" }> =>
1294+
e.type === "inference.error",
1295+
);
1296+
}
1297+
1298+
function usageEvents(events: InferenceEvent[]) {
1299+
return events.filter(
1300+
(e): e is Extract<InferenceEvent, { type: "inference.usage" }> =>
1301+
e.type === "inference.usage",
1302+
);
1303+
}
1304+
1305+
test("message_delta stop_reason surfaces on the usage event", async () => {
1306+
const { events } = await driveTurn(TRUNCATED_STREAM, "text/event-stream");
1307+
const usage = usageEvents(events);
1308+
expect(usage.length).toBeGreaterThan(0);
1309+
expect(usage[usage.length - 1]?.data.stopReason).toBe("max_tokens");
1310+
});
1311+
1312+
test("truncated call fails the turn retryably; no tool_call is dispatched", async () => {
1313+
const { turn, events } = await driveTurn(
1314+
TRUNCATED_STREAM,
1315+
"text/event-stream",
1316+
);
1317+
expect(turn).toBeUndefined();
1318+
expect(events.some((e) => e.type === "inference.done")).toBe(false);
1319+
expect(
1320+
events.some((e) => e.type === "inference.tool_call.end"),
1321+
).toBe(false);
1322+
const errors = errorEvents(events);
1323+
expect(errors).toHaveLength(1);
1324+
expect(errors[0]?.data.error.category).toBe("retryable");
1325+
expect(errors[0]?.data.error.message).toContain("max_tokens");
1326+
expect(errors[0]?.data.error.message).toContain("Bash");
1327+
expect(errors[0]?.data.error.message).toContain("not executed");
1328+
});
1329+
1330+
test("unparseable args with a non-truncation stop reason still never dispatch", async () => {
1331+
const body = sse([
1332+
{
1333+
type: "content_block_start",
1334+
index: 0,
1335+
content_block: { type: "tool_use", id: "toolu_bad", name: "Bash" },
1336+
},
1337+
{
1338+
type: "content_block_delta",
1339+
index: 0,
1340+
delta: {
1341+
type: "input_json_delta",
1342+
partial_json: '{"command":',
1343+
},
1344+
},
1345+
{
1346+
type: "message_delta",
1347+
delta: { stop_reason: "end_turn" },
1348+
usage: { output_tokens: 12 },
1349+
},
1350+
{ type: "message_stop" },
1351+
]);
1352+
const { turn, events } = await driveTurn(body, "text/event-stream");
1353+
expect(turn).toBeUndefined();
1354+
expect(events.some((e) => e.type === "inference.done")).toBe(false);
1355+
expect(
1356+
events.some((e) => e.type === "inference.tool_call.end"),
1357+
).toBe(false);
1358+
const errors = errorEvents(events);
1359+
expect(errors).toHaveLength(1);
1360+
expect(errors[0]?.data.error.category).toBe("retryable");
1361+
expect(errors[0]?.data.error.message).toContain("not valid JSON");
1362+
});
1363+
1364+
test("non-streaming message surfaces top-level stop_reason on usage", async () => {
1365+
const body = JSON.stringify({
1366+
type: "message",
1367+
role: "assistant",
1368+
model: "claude-test",
1369+
content: [{ type: "text", text: "Done." }],
1370+
stop_reason: "end_turn",
1371+
usage: { input_tokens: 5, output_tokens: 3 },
1372+
});
1373+
const { events } = await driveTurn(body, "application/json");
1374+
expect(events.some((e) => e.type === "inference.error")).toBe(false);
1375+
const usage = usageEvents(events);
1376+
expect(usage).toHaveLength(1);
1377+
expect(usage[0]?.data.stopReason).toBe("end_turn");
1378+
});
1379+
});

vendor/intx-inference/src/providers/anthropic.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,8 @@ const ContentBlockStop = type({
554554

555555
const MessageDelta = type({
556556
type: "'message_delta'",
557+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call
558+
"delta?": { "stop_reason?": "string" },
557559
"usage?": { "output_tokens?": "number" },
558560
});
559561

@@ -816,11 +818,17 @@ function parseResponse(
816818
cacheWrite: 0,
817819
thinking: 0,
818820
};
821+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call
822+
const stopReason = event.delta?.stop_reason;
819823
return [
820824
{
821825
type: "inference.usage",
822826
seq,
823-
data: { usage: inferenceUsage, source },
827+
data: {
828+
usage: inferenceUsage,
829+
...(stopReason === undefined ? {} : { stopReason }),
830+
source,
831+
},
824832
},
825833
];
826834
}
@@ -869,6 +877,8 @@ const NonStreamingUsage = type({
869877
const NonStreamingMessage = type({
870878
type: "'message'",
871879
content: "unknown[]",
880+
// Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call
881+
"stop_reason?": "string",
872882
usage: NonStreamingUsage,
873883
});
874884

@@ -1064,7 +1074,13 @@ function parseJSONResponse(
10641074
events.push({
10651075
type: "inference.usage",
10661076
seq,
1067-
data: { usage: toInferenceUsage(message.usage), source },
1077+
data: {
1078+
usage: toInferenceUsage(message.usage),
1079+
...(message.stop_reason === undefined
1080+
? {}
1081+
: { stopReason: message.stop_reason }),
1082+
source,
1083+
},
10681084
});
10691085

10701086
return events;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { LastCycleSource } from "@intx/types/runtime";
3+
import { createGoogleGenAIAdapter } from "./google-genai";
4+
5+
const TEST_SOURCE: LastCycleSource = {
6+
sourceId: "test-google-genai",
7+
provider: "google-genai",
8+
model: "test-gemini-model",
9+
};
10+
11+
describe("google-genai adapter — finishReason forwarding (CL-7783)", () => {
12+
test("terminal finishReason surfaces on the usage event", () => {
13+
const adapter = createGoogleGenAIAdapter(TEST_SOURCE);
14+
const events = adapter.parseResponse(
15+
JSON.stringify({
16+
candidates: [
17+
{
18+
content: { parts: [{ text: "partial" }], role: "model" },
19+
finishReason: "MAX_TOKENS",
20+
index: 0,
21+
},
22+
],
23+
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 },
24+
}),
25+
);
26+
const usage = events.filter((e) => e.type === "inference.usage");
27+
expect(usage).toHaveLength(1);
28+
expect(usage[0]?.data.stopReason).toBe("MAX_TOKENS");
29+
});
30+
31+
test("non-terminal event without finishReason emits no usage", () => {
32+
const adapter = createGoogleGenAIAdapter(TEST_SOURCE);
33+
const events = adapter.parseResponse(
34+
JSON.stringify({
35+
candidates: [
36+
{
37+
content: { parts: [{ text: "partial" }], role: "model" },
38+
index: 0,
39+
},
40+
],
41+
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 },
42+
}),
43+
);
44+
expect(events.some((e) => e.type === "inference.usage")).toBe(false);
45+
});
46+
});

0 commit comments

Comments
 (0)