From 69aae63b43632b2568348c3aae208d294cf709f1 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:49:25 -0400 Subject: [PATCH 1/5] fix(agent): do not execute tool calls from a max_output_tokens-truncated response A response the provider stopped at max_output_tokens can still carry a function_call item whose arguments are whatever prefix fit in the budget. The loop parsed that fragment, failed, fed the parse error back to the model, and issued another request against the same exhausted budget, so every truncated turn cost one wasted round trip. extractToolCallsFromResponse and responseHasToolCalls now yield no tool calls when status is incomplete with reason max_output_tokens. The loop finalizes on the truncated turn and the caller sees incompleteDetails. --- packages/agent/src/lib/stream-transformers.ts | 26 +++- .../unit/max-output-tokens-truncation.test.ts | 125 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/agent/tests/unit/max-output-tokens-truncation.test.ts diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index a6e24f95..62357ad6 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -805,15 +805,34 @@ export function extractTextFromResponse(response: models.OpenResponsesResult): s return ''; } +/** + * Whether the provider stopped this response at `max_output_tokens`. The + * `function_call` items on such a response carry whatever argument prefix fit + * in the budget, so they are not calls the model made: executing them parses a + * fragment, and re-requesting on the same budget truncates the same way. + */ +function isTruncatedAtMaxOutputTokens(response: models.OpenResponsesResult): boolean { + return ( + response.status === 'incomplete' && response.incompleteDetails?.reason === 'max_output_tokens' + ); +} + /** * Extract all tool calls from a completed response * Returns parsed tool calls with arguments as objects (not JSON strings) + * + * A response truncated at `max_output_tokens` yields no tool calls: see + * `isTruncatedAtMaxOutputTokens`. */ export function extractToolCallsFromResponse( response: models.OpenResponsesResult, ): ParsedToolCall[] { const toolCalls: ParsedToolCall[] = []; + if (isTruncatedAtMaxOutputTokens(response)) { + return toolCalls; + } + for (const item of response.output) { if (isFunctionCallItem(item)) { try { @@ -959,9 +978,14 @@ export async function* buildToolCallStream( } /** - * Check if a response contains any tool calls + * Check if a response contains any tool calls the loop should execute. A + * response truncated at `max_output_tokens` has none, even when its output + * carries a cut-off `function_call` item. */ export function responseHasToolCalls(response: models.OpenResponsesResult): boolean { + if (isTruncatedAtMaxOutputTokens(response)) { + return false; + } return response.output.some((item) => 'type' in item && item.type === 'function_call'); } diff --git a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts new file mode 100644 index 00000000..06a7abe1 --- /dev/null +++ b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts @@ -0,0 +1,125 @@ +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import type * as models from '@openrouter/sdk/models'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; + +const mockBetaResponsesSend = vi.hoisted(() => vi.fn()); + +vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({ + betaResponsesSend: mockBetaResponsesSend, +})); + +import { callModel } from '../../src/inner-loop/call-model.js'; +import { stepCountIs } from '../../src/lib/stop-conditions.js'; +import { + extractToolCallsFromResponse, + responseHasToolCalls, +} from '../../src/lib/stream-transformers.js'; +import { ToolType } from '../../src/lib/tool-types.js'; + +/** + * A turn the provider stopped at `max_output_tokens` two tokens into the tool + * call: a reasoning model spent the whole budget thinking. The `function_call` + * item is present but its arguments are a fragment. The loop must treat this + * as the end of the run, not as a call to execute or a reason to request again. + */ +function truncatedToolCallResponse(): models.OpenResponsesResult { + return { + id: 'resp_truncated', + object: 'response', + createdAt: 1_783_462_506, + completedAt: 1_783_462_520, + model: 'test-model', + status: 'incomplete', + incompleteDetails: { + reason: 'max_output_tokens', + }, + error: null, + output: [ + { + type: 'function_call', + id: 'fc_1', + callId: 'call_1', + name: 'run_shell', + arguments: '{"commands":', + status: 'incomplete', + }, + ], + usage: { + inputTokens: 4529, + inputTokensDetails: { + cachedTokens: 0, + }, + outputTokens: 3002, + totalTokens: 7531, + outputTokensDetails: { + reasoningTokens: 3000, + }, + }, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, + } as models.OpenResponsesResult; +} + +const client = {} as OpenRouterCore; + +describe('max_output_tokens truncation', () => { + beforeEach(() => { + mockBetaResponsesSend.mockReset(); + }); + + it('extracts no tool calls from a response truncated at max_output_tokens', () => { + const response = truncatedToolCallResponse(); + + expect(responseHasToolCalls(response)).toBe(false); + expect(extractToolCallsFromResponse(response)).toEqual([]); + }); + + it('finalizes on the truncated turn without executing the partial call or requesting again', async () => { + const executed: unknown[] = []; + mockBetaResponsesSend.mockResolvedValue({ + ok: true, + value: truncatedToolCallResponse(), + }); + + const result = callModel(client, { + model: 'test-model', + input: 'Run echo hello.', + // Bounds the run so a regression (re-requesting on the exhausted budget) + // fails on the request count rather than looping until the worker dies. + stopWhen: stepCountIs(3), + tools: [ + { + type: ToolType.Function, + function: { + name: 'run_shell', + description: 'Run shell commands.', + inputSchema: z.object({ + commands: z.array(z.string()), + }), + execute: async (params: { commands: string[] }) => { + executed.push(params); + return { + ok: true, + }; + }, + }, + }, + ] as const, + }); + + const response = await result.getResponse(); + + expect(executed).toEqual([]); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + expect(response.id).toBe('resp_truncated'); + expect(response.status).toBe('incomplete'); + }); +}); From b2125460b1ec3fbb13cb6d31c0400bc02e195e77 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:25:26 -0400 Subject: [PATCH 2/5] fix(agent): filter truncated calls per item, keep calls completed before the cut-off Review feedback: a parallel turn can complete one function_call and be cut off at max_output_tokens during the next. Rejecting the whole response discarded the completed call. Filter per item instead: on a truncated response a function_call is executable iff its status is completed (arguments must parse when the provider omits status). The dropped item is also excluded from state and from the next request's input, so no call is echoed without an output. --- packages/agent/src/lib/model-result.ts | 25 +- packages/agent/src/lib/stream-transformers.ts | 63 +++-- .../unit/max-output-tokens-truncation.test.ts | 248 ++++++++++++++---- 3 files changed, 248 insertions(+), 88 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index bbc4f047..85a0c3ba 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -76,6 +76,7 @@ import { extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, + outputItemsWithoutTruncatedCalls, responseHasToolCalls, streamTerminationEvents, tryExtractCompletionFromBuffer, @@ -1208,11 +1209,7 @@ export class ModelResult< return; } - const outputItems = Array.isArray(response.output) - ? response.output - : [ - response.output, - ]; + const outputItems = outputItemsWithoutTruncatedCalls(response); // Persist pending fresh user items together with the assistant output // so they land atomically — if the stream failed before reaching here @@ -4817,11 +4814,7 @@ export class ModelResult< const newInput: models.InputsUnion = [ ...normalizedOriginalInput, - ...(Array.isArray(currentResponse.output) - ? currentResponse.output - : [ - currentResponse.output, - ]), + ...outputItemsWithoutTruncatedCalls(currentResponse), ...toolResults, ]; @@ -4897,11 +4890,7 @@ export class ModelResult< ...this.resolvedRequest, input: [ ...normalizedOriginalInput, - ...(Array.isArray(response.output) - ? response.output - : [ - response.output, - ]), + ...outputItemsWithoutTruncatedCalls(response), ], }; } @@ -5127,11 +5116,7 @@ export class ModelResult< const newInput: models.InputsUnion = [ ...normalizedOriginalInput, - ...(Array.isArray(currentResponse.output) - ? currentResponse.output - : [ - currentResponse.output, - ]), + ...outputItemsWithoutTruncatedCalls(currentResponse), ...toolOutputs, ...(typeof finalDirective === 'string' && finalDirective.length > 0 ? [ diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index 62357ad6..737c2734 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -805,35 +805,61 @@ export function extractTextFromResponse(response: models.OpenResponsesResult): s return ''; } -/** - * Whether the provider stopped this response at `max_output_tokens`. The - * `function_call` items on such a response carry whatever argument prefix fit - * in the budget, so they are not calls the model made: executing them parses a - * fragment, and re-requesting on the same budget truncates the same way. - */ function isTruncatedAtMaxOutputTokens(response: models.OpenResponsesResult): boolean { return ( response.status === 'incomplete' && response.incompleteDetails?.reason === 'max_output_tokens' ); } +/** + * Whether a `function_call` on a `max_output_tokens`-truncated response was + * cut off. The provider marks the item `incomplete`; when it omits the status + * the arguments decide, since a fragment cannot parse. + */ +function isTruncatedFunctionCall(item: models.OutputFunctionCallItem): boolean { + if (item.status !== undefined) { + return item.status !== 'completed'; + } + try { + JSON.parse(item.arguments.trim() || '{}'); + return false; + } catch { + return true; + } +} + +/** + * The response's output items minus any `function_call` the provider cut off + * at `max_output_tokens`. Such an item carries whatever argument prefix fit in + * the budget: it is not a call the model made, executing it parses a fragment, + * and echoing it into the next request leaves a call with no output. Calls + * that completed before the cut-off are kept and execute normally. On any + * other response this is `response.output` unchanged. + */ +export function outputItemsWithoutTruncatedCalls( + response: models.OpenResponsesResult, +): models.OpenResponsesResult['output'] { + if (!isTruncatedAtMaxOutputTokens(response)) { + return response.output; + } + return response.output.filter( + (item) => !isFunctionCallItem(item) || !isTruncatedFunctionCall(item), + ); +} + /** * Extract all tool calls from a completed response * Returns parsed tool calls with arguments as objects (not JSON strings) * - * A response truncated at `max_output_tokens` yields no tool calls: see - * `isTruncatedAtMaxOutputTokens`. + * Calls cut off at `max_output_tokens` are excluded: see + * `outputItemsWithoutTruncatedCalls`. */ export function extractToolCallsFromResponse( response: models.OpenResponsesResult, ): ParsedToolCall[] { const toolCalls: ParsedToolCall[] = []; - if (isTruncatedAtMaxOutputTokens(response)) { - return toolCalls; - } - - for (const item of response.output) { + for (const item of outputItemsWithoutTruncatedCalls(response)) { if (isFunctionCallItem(item)) { try { const trimmedArgs = item.arguments.trim(); @@ -979,14 +1005,13 @@ export async function* buildToolCallStream( /** * Check if a response contains any tool calls the loop should execute. A - * response truncated at `max_output_tokens` has none, even when its output - * carries a cut-off `function_call` item. + * `function_call` cut off at `max_output_tokens` does not count: see + * `outputItemsWithoutTruncatedCalls`. */ export function responseHasToolCalls(response: models.OpenResponsesResult): boolean { - if (isTruncatedAtMaxOutputTokens(response)) { - return false; - } - return response.output.some((item) => 'type' in item && item.type === 'function_call'); + return outputItemsWithoutTruncatedCalls(response).some( + (item) => 'type' in item && item.type === 'function_call', + ); } /** diff --git a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts index 06a7abe1..bd4db08e 100644 --- a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts +++ b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts @@ -17,6 +17,53 @@ import { } from '../../src/lib/stream-transformers.js'; import { ToolType } from '../../src/lib/tool-types.js'; +const RESPONSE_BASE = { + object: 'response', + createdAt: 1_783_462_506, + completedAt: 1_783_462_520, + model: 'test-model', + error: null, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: true, +} as const; + +const USAGE = { + inputTokens: 4529, + inputTokensDetails: { + cachedTokens: 0, + }, + outputTokens: 3002, + totalTokens: 7531, + outputTokensDetails: { + reasoningTokens: 3000, + }, +} as const; + +const TRUNCATED_SHELL_CALL = { + type: 'function_call', + id: 'fc_shell', + callId: 'call_shell', + name: 'run_shell', + arguments: '{"commands":', + status: 'incomplete', +} as const; + +const COMPLETED_WEATHER_CALL = { + type: 'function_call', + id: 'fc_weather', + callId: 'call_weather', + name: 'get_weather', + arguments: '{"city":"Paris"}', + status: 'completed', +} as const; + /** * A turn the provider stopped at `max_output_tokens` two tokens into the tool * call: a reasoning model spent the whole budget thinking. The `function_call` @@ -25,65 +72,134 @@ import { ToolType } from '../../src/lib/tool-types.js'; */ function truncatedToolCallResponse(): models.OpenResponsesResult { return { + ...RESPONSE_BASE, id: 'resp_truncated', - object: 'response', - createdAt: 1_783_462_506, - completedAt: 1_783_462_520, - model: 'test-model', status: 'incomplete', incompleteDetails: { reason: 'max_output_tokens', }, - error: null, + output: [ + TRUNCATED_SHELL_CALL, + ], + usage: USAGE, + } as models.OpenResponsesResult; +} + +/** A parallel turn that completed one call and was cut off during the next. */ +function mixedTruncatedResponse(): models.OpenResponsesResult { + return { + ...RESPONSE_BASE, + id: 'resp_mixed', + status: 'incomplete', + incompleteDetails: { + reason: 'max_output_tokens', + }, + output: [ + COMPLETED_WEATHER_CALL, + TRUNCATED_SHELL_CALL, + ], + usage: USAGE, + } as models.OpenResponsesResult; +} + +function textResponse(text: string): models.OpenResponsesResult { + return { + ...RESPONSE_BASE, + id: 'resp_final', + status: 'completed', + incompleteDetails: null, output: [ { - type: 'function_call', - id: 'fc_1', - callId: 'call_1', - name: 'run_shell', - arguments: '{"commands":', - status: 'incomplete', + type: 'message', + id: 'msg_final', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], }, ], - usage: { - inputTokens: 4529, - inputTokensDetails: { - cachedTokens: 0, - }, - outputTokens: 3002, - totalTokens: 7531, - outputTokensDetails: { - reasoningTokens: 3000, - }, - }, - temperature: null, - topP: null, - presencePenalty: null, - frequencyPenalty: null, - metadata: null, - instructions: null, - tools: [], - toolChoice: 'auto', - parallelToolCalls: false, + usage: USAGE, } as models.OpenResponsesResult; } +const executed: unknown[] = []; + +const shellTool = { + type: ToolType.Function, + function: { + name: 'run_shell', + description: 'Run shell commands.', + inputSchema: z.object({ + commands: z.array(z.string()), + }), + execute: async (params: { commands: string[] }) => { + executed.push({ + tool: 'run_shell', + ...params, + }); + return { + ok: true, + }; + }, + }, +} as const; + +const weatherTool = { + type: ToolType.Function, + function: { + name: 'get_weather', + description: 'Get the weather.', + inputSchema: z.object({ + city: z.string(), + }), + execute: async (params: { city: string }) => { + executed.push({ + tool: 'get_weather', + ...params, + }); + return { + temperature: 22, + }; + }, + }, +} as const; + const client = {} as OpenRouterCore; describe('max_output_tokens truncation', () => { beforeEach(() => { mockBetaResponsesSend.mockReset(); + executed.length = 0; }); - it('extracts no tool calls from a response truncated at max_output_tokens', () => { + it('extracts no tool calls from a response truncated before any call completed', () => { const response = truncatedToolCallResponse(); expect(responseHasToolCalls(response)).toBe(false); expect(extractToolCallsFromResponse(response)).toEqual([]); }); + it('keeps the calls that completed before the cut-off and drops the truncated one', () => { + const response = mixedTruncatedResponse(); + + expect(responseHasToolCalls(response)).toBe(true); + expect(extractToolCallsFromResponse(response)).toEqual([ + { + id: 'call_weather', + name: 'get_weather', + arguments: { + city: 'Paris', + }, + }, + ]); + }); + it('finalizes on the truncated turn without executing the partial call or requesting again', async () => { - const executed: unknown[] = []; mockBetaResponsesSend.mockResolvedValue({ ok: true, value: truncatedToolCallResponse(), @@ -96,22 +212,7 @@ describe('max_output_tokens truncation', () => { // fails on the request count rather than looping until the worker dies. stopWhen: stepCountIs(3), tools: [ - { - type: ToolType.Function, - function: { - name: 'run_shell', - description: 'Run shell commands.', - inputSchema: z.object({ - commands: z.array(z.string()), - }), - execute: async (params: { commands: string[] }) => { - executed.push(params); - return { - ok: true, - }; - }, - }, - }, + shellTool, ] as const, }); @@ -122,4 +223,53 @@ describe('max_output_tokens truncation', () => { expect(response.id).toBe('resp_truncated'); expect(response.status).toBe('incomplete'); }); + + it('executes the completed call and omits the truncated one from the follow-up request', async () => { + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: mixedTruncatedResponse(), + }) + .mockResolvedValueOnce({ + ok: true, + value: textResponse('It is 22 degrees in Paris.'), + }); + + const result = callModel(client, { + model: 'test-model', + input: 'Weather in Paris, then run echo hello.', + stopWhen: stepCountIs(3), + tools: [ + weatherTool, + shellTool, + ] as const, + }); + + const text = await result.getText(); + + expect(text).toBe('It is 22 degrees in Paris.'); + expect(executed).toEqual([ + { + tool: 'get_weather', + city: 'Paris', + }, + ]); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + + const followUp = mockBetaResponsesSend.mock.calls[1]?.[1]?.responsesRequest; + const input = followUp.input as { + type?: string; + callId?: string; + }[]; + const functionCalls = input.filter((item) => item.type === 'function_call'); + const outputs = input.filter((item) => item.type === 'function_call_output'); + // The truncated shell call is not echoed: a call with no output would be + // rejected by the provider, and the model never made it. + expect(functionCalls.map((item) => item.callId)).toEqual([ + 'call_weather', + ]); + expect(outputs.map((item) => item.callId)).toEqual([ + 'call_weather', + ]); + }); }); From 10236d91b3da6741bf114725e2afcc368c4dae85 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:47:35 -0400 Subject: [PATCH 3/5] Revert "fix(agent): filter truncated calls per item, keep calls completed before the cut-off" This reverts commit b2125460b1ec3fbb13cb6d31c0400bc02e195e77. --- packages/agent/src/lib/model-result.ts | 25 +- packages/agent/src/lib/stream-transformers.ts | 63 ++--- .../unit/max-output-tokens-truncation.test.ts | 248 ++++-------------- 3 files changed, 88 insertions(+), 248 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 85a0c3ba..bbc4f047 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -76,7 +76,6 @@ import { extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, - outputItemsWithoutTruncatedCalls, responseHasToolCalls, streamTerminationEvents, tryExtractCompletionFromBuffer, @@ -1209,7 +1208,11 @@ export class ModelResult< return; } - const outputItems = outputItemsWithoutTruncatedCalls(response); + const outputItems = Array.isArray(response.output) + ? response.output + : [ + response.output, + ]; // Persist pending fresh user items together with the assistant output // so they land atomically — if the stream failed before reaching here @@ -4814,7 +4817,11 @@ export class ModelResult< const newInput: models.InputsUnion = [ ...normalizedOriginalInput, - ...outputItemsWithoutTruncatedCalls(currentResponse), + ...(Array.isArray(currentResponse.output) + ? currentResponse.output + : [ + currentResponse.output, + ]), ...toolResults, ]; @@ -4890,7 +4897,11 @@ export class ModelResult< ...this.resolvedRequest, input: [ ...normalizedOriginalInput, - ...outputItemsWithoutTruncatedCalls(response), + ...(Array.isArray(response.output) + ? response.output + : [ + response.output, + ]), ], }; } @@ -5116,7 +5127,11 @@ export class ModelResult< const newInput: models.InputsUnion = [ ...normalizedOriginalInput, - ...outputItemsWithoutTruncatedCalls(currentResponse), + ...(Array.isArray(currentResponse.output) + ? currentResponse.output + : [ + currentResponse.output, + ]), ...toolOutputs, ...(typeof finalDirective === 'string' && finalDirective.length > 0 ? [ diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index 737c2734..62357ad6 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -805,61 +805,35 @@ export function extractTextFromResponse(response: models.OpenResponsesResult): s return ''; } +/** + * Whether the provider stopped this response at `max_output_tokens`. The + * `function_call` items on such a response carry whatever argument prefix fit + * in the budget, so they are not calls the model made: executing them parses a + * fragment, and re-requesting on the same budget truncates the same way. + */ function isTruncatedAtMaxOutputTokens(response: models.OpenResponsesResult): boolean { return ( response.status === 'incomplete' && response.incompleteDetails?.reason === 'max_output_tokens' ); } -/** - * Whether a `function_call` on a `max_output_tokens`-truncated response was - * cut off. The provider marks the item `incomplete`; when it omits the status - * the arguments decide, since a fragment cannot parse. - */ -function isTruncatedFunctionCall(item: models.OutputFunctionCallItem): boolean { - if (item.status !== undefined) { - return item.status !== 'completed'; - } - try { - JSON.parse(item.arguments.trim() || '{}'); - return false; - } catch { - return true; - } -} - -/** - * The response's output items minus any `function_call` the provider cut off - * at `max_output_tokens`. Such an item carries whatever argument prefix fit in - * the budget: it is not a call the model made, executing it parses a fragment, - * and echoing it into the next request leaves a call with no output. Calls - * that completed before the cut-off are kept and execute normally. On any - * other response this is `response.output` unchanged. - */ -export function outputItemsWithoutTruncatedCalls( - response: models.OpenResponsesResult, -): models.OpenResponsesResult['output'] { - if (!isTruncatedAtMaxOutputTokens(response)) { - return response.output; - } - return response.output.filter( - (item) => !isFunctionCallItem(item) || !isTruncatedFunctionCall(item), - ); -} - /** * Extract all tool calls from a completed response * Returns parsed tool calls with arguments as objects (not JSON strings) * - * Calls cut off at `max_output_tokens` are excluded: see - * `outputItemsWithoutTruncatedCalls`. + * A response truncated at `max_output_tokens` yields no tool calls: see + * `isTruncatedAtMaxOutputTokens`. */ export function extractToolCallsFromResponse( response: models.OpenResponsesResult, ): ParsedToolCall[] { const toolCalls: ParsedToolCall[] = []; - for (const item of outputItemsWithoutTruncatedCalls(response)) { + if (isTruncatedAtMaxOutputTokens(response)) { + return toolCalls; + } + + for (const item of response.output) { if (isFunctionCallItem(item)) { try { const trimmedArgs = item.arguments.trim(); @@ -1005,13 +979,14 @@ export async function* buildToolCallStream( /** * Check if a response contains any tool calls the loop should execute. A - * `function_call` cut off at `max_output_tokens` does not count: see - * `outputItemsWithoutTruncatedCalls`. + * response truncated at `max_output_tokens` has none, even when its output + * carries a cut-off `function_call` item. */ export function responseHasToolCalls(response: models.OpenResponsesResult): boolean { - return outputItemsWithoutTruncatedCalls(response).some( - (item) => 'type' in item && item.type === 'function_call', - ); + if (isTruncatedAtMaxOutputTokens(response)) { + return false; + } + return response.output.some((item) => 'type' in item && item.type === 'function_call'); } /** diff --git a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts index bd4db08e..06a7abe1 100644 --- a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts +++ b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts @@ -17,53 +17,6 @@ import { } from '../../src/lib/stream-transformers.js'; import { ToolType } from '../../src/lib/tool-types.js'; -const RESPONSE_BASE = { - object: 'response', - createdAt: 1_783_462_506, - completedAt: 1_783_462_520, - model: 'test-model', - error: null, - temperature: null, - topP: null, - presencePenalty: null, - frequencyPenalty: null, - metadata: null, - instructions: null, - tools: [], - toolChoice: 'auto', - parallelToolCalls: true, -} as const; - -const USAGE = { - inputTokens: 4529, - inputTokensDetails: { - cachedTokens: 0, - }, - outputTokens: 3002, - totalTokens: 7531, - outputTokensDetails: { - reasoningTokens: 3000, - }, -} as const; - -const TRUNCATED_SHELL_CALL = { - type: 'function_call', - id: 'fc_shell', - callId: 'call_shell', - name: 'run_shell', - arguments: '{"commands":', - status: 'incomplete', -} as const; - -const COMPLETED_WEATHER_CALL = { - type: 'function_call', - id: 'fc_weather', - callId: 'call_weather', - name: 'get_weather', - arguments: '{"city":"Paris"}', - status: 'completed', -} as const; - /** * A turn the provider stopped at `max_output_tokens` two tokens into the tool * call: a reasoning model spent the whole budget thinking. The `function_call` @@ -72,134 +25,65 @@ const COMPLETED_WEATHER_CALL = { */ function truncatedToolCallResponse(): models.OpenResponsesResult { return { - ...RESPONSE_BASE, id: 'resp_truncated', + object: 'response', + createdAt: 1_783_462_506, + completedAt: 1_783_462_520, + model: 'test-model', status: 'incomplete', incompleteDetails: { reason: 'max_output_tokens', }, - output: [ - TRUNCATED_SHELL_CALL, - ], - usage: USAGE, - } as models.OpenResponsesResult; -} - -/** A parallel turn that completed one call and was cut off during the next. */ -function mixedTruncatedResponse(): models.OpenResponsesResult { - return { - ...RESPONSE_BASE, - id: 'resp_mixed', - status: 'incomplete', - incompleteDetails: { - reason: 'max_output_tokens', - }, - output: [ - COMPLETED_WEATHER_CALL, - TRUNCATED_SHELL_CALL, - ], - usage: USAGE, - } as models.OpenResponsesResult; -} - -function textResponse(text: string): models.OpenResponsesResult { - return { - ...RESPONSE_BASE, - id: 'resp_final', - status: 'completed', - incompleteDetails: null, + error: null, output: [ { - type: 'message', - id: 'msg_final', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text, - annotations: [], - }, - ], + type: 'function_call', + id: 'fc_1', + callId: 'call_1', + name: 'run_shell', + arguments: '{"commands":', + status: 'incomplete', }, ], - usage: USAGE, + usage: { + inputTokens: 4529, + inputTokensDetails: { + cachedTokens: 0, + }, + outputTokens: 3002, + totalTokens: 7531, + outputTokensDetails: { + reasoningTokens: 3000, + }, + }, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, } as models.OpenResponsesResult; } -const executed: unknown[] = []; - -const shellTool = { - type: ToolType.Function, - function: { - name: 'run_shell', - description: 'Run shell commands.', - inputSchema: z.object({ - commands: z.array(z.string()), - }), - execute: async (params: { commands: string[] }) => { - executed.push({ - tool: 'run_shell', - ...params, - }); - return { - ok: true, - }; - }, - }, -} as const; - -const weatherTool = { - type: ToolType.Function, - function: { - name: 'get_weather', - description: 'Get the weather.', - inputSchema: z.object({ - city: z.string(), - }), - execute: async (params: { city: string }) => { - executed.push({ - tool: 'get_weather', - ...params, - }); - return { - temperature: 22, - }; - }, - }, -} as const; - const client = {} as OpenRouterCore; describe('max_output_tokens truncation', () => { beforeEach(() => { mockBetaResponsesSend.mockReset(); - executed.length = 0; }); - it('extracts no tool calls from a response truncated before any call completed', () => { + it('extracts no tool calls from a response truncated at max_output_tokens', () => { const response = truncatedToolCallResponse(); expect(responseHasToolCalls(response)).toBe(false); expect(extractToolCallsFromResponse(response)).toEqual([]); }); - it('keeps the calls that completed before the cut-off and drops the truncated one', () => { - const response = mixedTruncatedResponse(); - - expect(responseHasToolCalls(response)).toBe(true); - expect(extractToolCallsFromResponse(response)).toEqual([ - { - id: 'call_weather', - name: 'get_weather', - arguments: { - city: 'Paris', - }, - }, - ]); - }); - it('finalizes on the truncated turn without executing the partial call or requesting again', async () => { + const executed: unknown[] = []; mockBetaResponsesSend.mockResolvedValue({ ok: true, value: truncatedToolCallResponse(), @@ -212,7 +96,22 @@ describe('max_output_tokens truncation', () => { // fails on the request count rather than looping until the worker dies. stopWhen: stepCountIs(3), tools: [ - shellTool, + { + type: ToolType.Function, + function: { + name: 'run_shell', + description: 'Run shell commands.', + inputSchema: z.object({ + commands: z.array(z.string()), + }), + execute: async (params: { commands: string[] }) => { + executed.push(params); + return { + ok: true, + }; + }, + }, + }, ] as const, }); @@ -223,53 +122,4 @@ describe('max_output_tokens truncation', () => { expect(response.id).toBe('resp_truncated'); expect(response.status).toBe('incomplete'); }); - - it('executes the completed call and omits the truncated one from the follow-up request', async () => { - mockBetaResponsesSend - .mockResolvedValueOnce({ - ok: true, - value: mixedTruncatedResponse(), - }) - .mockResolvedValueOnce({ - ok: true, - value: textResponse('It is 22 degrees in Paris.'), - }); - - const result = callModel(client, { - model: 'test-model', - input: 'Weather in Paris, then run echo hello.', - stopWhen: stepCountIs(3), - tools: [ - weatherTool, - shellTool, - ] as const, - }); - - const text = await result.getText(); - - expect(text).toBe('It is 22 degrees in Paris.'); - expect(executed).toEqual([ - { - tool: 'get_weather', - city: 'Paris', - }, - ]); - expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); - - const followUp = mockBetaResponsesSend.mock.calls[1]?.[1]?.responsesRequest; - const input = followUp.input as { - type?: string; - callId?: string; - }[]; - const functionCalls = input.filter((item) => item.type === 'function_call'); - const outputs = input.filter((item) => item.type === 'function_call_output'); - // The truncated shell call is not echoed: a call with no output would be - // rejected by the provider, and the model never made it. - expect(functionCalls.map((item) => item.callId)).toEqual([ - 'call_weather', - ]); - expect(outputs.map((item) => item.callId)).toEqual([ - 'call_weather', - ]); - }); }); From 703e23607bccf747728308121232f14eb364f3b1 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:48:53 -0400 Subject: [PATCH 4/5] fix(agent): pin the batch rule for max_output_tokens truncation A truncated turn's function_call items are one unfinished batch, so none of them execute - including the ones completed before the cut-off. Running the complete prefix would hand the model a result set with a silent hole and spend side effects on a turn that truncates the same way on the same budget. Adds the mixed-batch case (three completed calls plus one cut call) asserting zero executions, one request, and the full output returned to the caller. --- packages/agent/src/lib/stream-transformers.ts | 10 +- .../unit/max-output-tokens-truncation.test.ts | 117 ++++++++++++++++-- 2 files changed, 113 insertions(+), 14 deletions(-) diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index 62357ad6..f42da090 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -807,9 +807,13 @@ export function extractTextFromResponse(response: models.OpenResponsesResult): s /** * Whether the provider stopped this response at `max_output_tokens`. The - * `function_call` items on such a response carry whatever argument prefix fit - * in the budget, so they are not calls the model made: executing them parses a - * fragment, and re-requesting on the same budget truncates the same way. + * `function_call` items on such a response are an unfinished batch: the model + * asked for the set together, and the last one carries whatever argument + * prefix fit in the budget. Executing the complete ones would hand the model a + * result set with a silent hole, and spend side effects on a turn that + * truncates the same way on the same budget, so none of them run. The caller + * sees every item and `incomplete_details`, and resumes once the budget is + * raised. */ function isTruncatedAtMaxOutputTokens(response: models.OpenResponsesResult): boolean { return ( diff --git a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts index 06a7abe1..4800cdb7 100644 --- a/packages/agent/tests/unit/max-output-tokens-truncation.test.ts +++ b/packages/agent/tests/unit/max-output-tokens-truncation.test.ts @@ -23,7 +23,18 @@ import { ToolType } from '../../src/lib/tool-types.js'; * item is present but its arguments are a fragment. The loop must treat this * as the end of the run, not as a call to execute or a reason to request again. */ -function truncatedToolCallResponse(): models.OpenResponsesResult { +function truncatedToolCallResponse( + output: models.OpenResponsesResult['output'] = [ + { + type: 'function_call', + id: 'fc_1', + callId: 'call_1', + name: 'run_shell', + arguments: '{"commands":', + status: 'incomplete', + }, + ], +): models.OpenResponsesResult { return { id: 'resp_truncated', object: 'response', @@ -35,16 +46,7 @@ function truncatedToolCallResponse(): models.OpenResponsesResult { reason: 'max_output_tokens', }, error: null, - output: [ - { - type: 'function_call', - id: 'fc_1', - callId: 'call_1', - name: 'run_shell', - arguments: '{"commands":', - status: 'incomplete', - }, - ], + output, usage: { inputTokens: 4529, inputTokensDetails: { @@ -68,6 +70,38 @@ function truncatedToolCallResponse(): models.OpenResponsesResult { } as models.OpenResponsesResult; } +/** + * The same cut-off after a parallel batch: three weather calls completed + * before the budget ran out inside the fourth call. The batch is one plan; + * running the three would leave the model a result set with a silent hole. + */ +function truncatedBatchResponse(): models.OpenResponsesResult { + return truncatedToolCallResponse([ + ...[ + 'Paris', + 'London', + 'Tokyo', + ].map((city, index) => ({ + type: 'function_call' as const, + id: `fc_weather_${index}`, + callId: `call_weather_${index}`, + name: 'get_weather', + arguments: JSON.stringify({ + city, + }), + status: 'completed' as const, + })), + { + type: 'function_call', + id: 'fc_shell', + callId: 'call_shell', + name: 'run_shell', + arguments: '{"commands":', + status: 'incomplete', + }, + ]); +} + const client = {} as OpenRouterCore; describe('max_output_tokens truncation', () => { @@ -122,4 +156,65 @@ describe('max_output_tokens truncation', () => { expect(response.id).toBe('resp_truncated'); expect(response.status).toBe('incomplete'); }); + + it('does not execute the calls that completed before the cut-off either', async () => { + const executed: unknown[] = []; + mockBetaResponsesSend.mockResolvedValue({ + ok: true, + value: truncatedBatchResponse(), + }); + + const result = callModel(client, { + model: 'test-model', + input: 'Weather in three cities, then run echo hello.', + stopWhen: stepCountIs(3), + tools: [ + { + type: ToolType.Function, + function: { + name: 'get_weather', + description: 'Get the weather.', + inputSchema: z.object({ + city: z.string(), + }), + execute: async (params: { city: string }) => { + executed.push(params); + return { + temperature: 22, + }; + }, + }, + }, + { + type: ToolType.Function, + function: { + name: 'run_shell', + description: 'Run shell commands.', + inputSchema: z.object({ + commands: z.array(z.string()), + }), + execute: async (params: { commands: string[] }) => { + executed.push(params); + return { + ok: true, + }; + }, + }, + }, + ] as const, + }); + + const response = await result.getResponse(); + + expect(executed).toEqual([]); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + expect(response.status).toBe('incomplete'); + // The caller gets the whole turn, cut-off item included, to resume from. + expect(response.output.map((item) => ('callId' in item ? item.callId : item.type))).toEqual([ + 'call_weather_0', + 'call_weather_1', + 'call_weather_2', + 'call_shell', + ]); + }); }); From e7de1a04ae2dd56f45645f02722a20ce6b81a051 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:13:32 -0400 Subject: [PATCH 5/5] docs(agent): note the streaming tool-call surface is outside the truncation guard --- packages/agent/src/lib/stream-transformers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index f42da090..66dac32c 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -985,6 +985,12 @@ export async function* buildToolCallStream( * Check if a response contains any tool calls the loop should execute. A * response truncated at `max_output_tokens` has none, even when its output * carries a cut-off `function_call` item. + * + * Scope: this and `extractToolCallsFromResponse` decide execution on the + * completed response. `buildToolCallStream` (`getToolCallsStream()`) is a + * consumer view that yields each call as its `output_item.done` arrives, + * before the terminal event says whether the turn was cut off, so it still + * reports the model's emitted calls, truncated one included. */ export function responseHasToolCalls(response: models.OpenResponsesResult): boolean { if (isTruncatedAtMaxOutputTokens(response)) {