Skip to content

fix(agent): do not execute tool calls from a max_output_tokens-truncated response - #125

Open
w0nche0l wants to merge 5 commits into
mainfrom
fix/skip-tool-calls-on-max-output-tokens-truncation
Open

w0nche0l wants to merge 5 commits into
mainfrom
fix/skip-tool-calls-on-max-output-tokens-truncation

Conversation

@w0nche0l

@w0nche0l w0nche0l commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Problem

When the provider stops a turn at max_output_tokens, the response carries status: incomplete, incomplete_details.reason: max_output_tokens, and whatever function_call items the model had emitted - the last one holding an argument prefix. The loop never looked at the response status: responseHasToolCalls saw a function_call item, extractToolCallsFromResponse tried to JSON.parse the fragment, the parse failure was fed back to the model as a tool error, and the loop requested again on the same exhausted budget. Each truncated turn cost one extra billed generation and produced a Failed to parse tool call arguments warning that was misclassified as a tool failure.

Fix

A response truncated at max_output_tokens yields no tool calls. responseHasToolCalls returns false and extractToolCallsFromResponse returns [], so the loop executes nothing, makes no follow-up request, and finalizes on the truncated response. The caller receives the whole turn - every item, including the cut-off function_call - plus incomplete_details, and resumes once the budget is raised.

This is response-level on purpose. The function_call items on a truncated turn are one unfinished batch: the model asked for the set together without seeing any result. Executing the complete prefix would hand the model a result set with a silent hole (the cut call is neither answered nor flagged) and spend side effects on a turn that truncates the same way on the same budget. It matches the Vercel AI SDK, whose loop refuses tool execution when the finish reason is length (isToolExecutionAllowedFinishReason, changelog 7.0.70), and OpenAI's hosted-shell behaviour, which executes nothing on a max_output_tokens turn.

Tests

tests/unit/max-output-tokens-truncation.test.ts drives the real callModel loop against a mocked betaResponsesSend:

  • a single cut-off call: zero executions, one request, response finalizes as incomplete
  • a batch of three completed calls plus one cut-off call: zero executions, one request, all four items returned to the caller

Both fail on main (the loop retries up to the step cap) and pass with the change.

Context: OpenRouterTeam/openrouter-web#43149 carries this as a patch on @openrouter/agent@0.10.0 until the next release.

…ted 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.
…ore 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.
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.

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve posted as COMMENT — the maintainer app lacks pull_requests:write on OpenRouterTeam, so the APPROVE event is rejected by GitHub. The assessment below is an approval; a human reviewer with write access can merge.

Perry's Review

Verdict: ✅ LGTM

The fix is at the right level: isTruncatedAtMaxOutputTokens gates both responseHasToolCalls and extractToolCallsFromResponse, so all 11 call sites in model-result.ts (the main loop gate at ~L6311, mid-loop re-extraction at ~L6336/6405, and consumer APIs getToolCalls/getText/etc.) are covered uniformly — no per-call-site patching needed.

Loop decision verified

At model-result.ts, the gate if (!hasToolCalls) { validateFinalResponse; finalResponse = currentResponse; markStateComplete; return } fires when responseHasToolCalls returns false. validateFinalResponse only throws on empty/missing output, and a truncated turn's output carries the cut-off function_call (non-empty), so it finalizes cleanly — no throw, no re-request, no execution. The tests confirm: one betaResponsesSend call, zero executions, status: 'incomplete' surfaced.

Premise checks out

Pre-fix extractToolCallsFromResponse caught the JSON.parse failure but still pushed the call with raw fragment args (arguments: item.arguments as unknown), so the loop executed the whole batch (completed-before-cutoff calls ran with real side effects), the fragment call failed as a tool error, and the loop re-requested on the same exhausted budget — exactly the wasted generation + misclassified shell error the PR describes. The response-level "execute nothing" choice avoids both. Matches Vercel AI SDK's isToolExecutionAllowedFinishReason (refuses on length) and OpenAI's hosted-shell behaviour.

Tests

Both scenarios (single cut-off call; mixed batch with completed + cut-off) assert zero executions, one request, and incomplete status. The mixed-batch test also asserts the full output (including the cut-off item) is returned to the caller. All fail on main. CI is green.

One non-blocking question (posted inline)

getToolCallsStream() (via buildToolCallStream) doesn't go through the new guard — it parses SSE output_item.done events directly, so it can still yield the cut-off call with raw fragment args, while getToolCalls() returns []. This isn't hit by the DEV-1188 path (which uses getResponse()), and buildToolCallStream yields incrementally before the terminal response.incomplete event arrives so it can't cheaply know the truncation status at yield-time. Worth a decision: document the divergence, or buffer until the terminal event.

Risk assessment

Risk: 🟢 Low

Estimated impact: Low — worst case if wrong is a recoverable no-op turn the caller re-requests after raising the budget.

Dimension Severity Risk Reasoning
Implementation risk 🟩 Low Small, well-placed change at the shared-helper level; 11 call sites covered uniformly; tests fail on main and pass with the change.
Premise risk 🟩 Low Diagnosis supported by production telemetry (3,628 parse errors / 7 days); design decision matches the Vercel AI SDK reference and was deliberated in the sibling PR.
Estimated impact 🟩 Low The change makes the loop more conservative (executes fewer calls, stops a wasted re-request); worst case if wrong is a recoverable no-op turn the caller re-requests after raising the budget.
Risk Factor Severity Risk Reasoning
Reversibility 🟩 Low Removing the guard restores prior behaviour; no persisted state is mutated.
Detectability 🟩 Low The truncated turn surfaces incomplete_details.reason to the caller immediately.
Blast radius 🟩 Low Only affects turns truncated at max_output_tokens carrying function_call items.
Data integrity None Low No persisted state is touched by the change.
Financial exposure 🟩 Low The change reduces billed generations by eliminating the wasted retry.
Security and privacy exposure None Low Nothing sensitive is reachable from the change.
Propagation 🟩 Low Downstream consumers (getToolCalls) see [] consistently; the streaming surface has a latent divergence (noted above).
Availability None Low The change cannot affect whether anything serves.
Recovery cost 🟩 Low A missed execution is recovered by the caller re-requesting with a higher budget.
Time to correct 🟩 Low A revert or guard broadening is a one-line change.

* carries a cut-off `function_call` item.
*/
export function responseHasToolCalls(response: models.OpenResponsesResult): boolean {
if (isTruncatedAtMaxOutputTokens(response)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: this guard covers the loop's execution decision (responseHasToolCalls → finalize) and extractToolCallsFromResponse, but the streaming surface getToolCallsStream()buildToolCallStream parses SSE output_item.done events directly and bypasses both. On a truncated turn it can still yield the cut-off call with raw fragment args (arguments: '{"commands":'), while getToolCalls() returns []. That divergence is latent (the DEV-1188 path uses getResponse(), not the streaming tool API) and buildToolCallStream yields incrementally before the terminal response.incomplete event, so it can't cheaply know the truncation status at yield-time. Is the divergence intentional (streaming reflects "what the model emitted," non-streaming reflects "what the loop executes"), or should the streaming surface buffer until the terminal event and suppress/flag truncated calls too? If intentional, a one-line note on responseHasToolCalls/extractToolCallsFromResponse documenting the scope would close the gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT-WRITTEN: Intentional. getToolCallsStream() is a consumer view of what the model emitted, yielded per output_item.done before the terminal event exists; the two guarded functions are the execution decision on the completed response. Buffering the stream until the terminal event would defeat its purpose. Added the scope note to the responseHasToolCalls docblock (mirrored in the openrouter-web patch).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant