fix(agent-core): record and close tool calls interrupted mid-stream - #1790
Conversation
When the provider stream breaks off mid-tool-call (pause_turn, engine
overload, token limit), the step ended with the partial tool call dropped
silently: never executed, never recorded. If the response carried no other
usable content, the persisted assistant message was effectively empty and
every subsequent request — including compaction — was rejected with
"assistant must not be empty" (HTTP 400), wedging the session.
Record each unexecuted call instead (arguments sanitized to {} when the
truncated JSON is unparseable) and immediately close it with a synthetic
interrupted error result. The exchange stays wire-valid, the history stays
truthful, and the model learns the calls never ran so it can re-issue them.
🦋 Changeset detectedLatest commit: d2c589f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2c589ffcf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| type: 'tool.result', | ||
| parentUuid: toolCall.id, | ||
| toolCallId: toolCall.id, | ||
| result: { output: UNEXECUTED_TOOL_CALL_OUTPUT, isError: true }, |
There was a problem hiding this comment.
Run finalize hooks for interrupted tool results
In contexts with a finalizeToolResult hook, a provider response that ends as paused, unknown, or max_tokens with tool calls now persists this synthetic error result directly, so the hook never sees it. Other error result paths, including preflight failures, are finalized before persistence; bypassing that here skips host redaction/budgeting and the Agent's PostToolUseFailure hook path for exactly these recorded tool.results. Please route the synthetic interrupted result through the same finalization path before dispatching it.
Useful? React with 👍 / 👎.
…oonshotAI#1790) When the provider stream breaks off mid-tool-call (pause_turn, engine overload, token limit), the step ended with the partial tool call dropped silently: never executed, never recorded. If the response carried no other usable content, the persisted assistant message was effectively empty and every subsequent request — including compaction — was rejected with "assistant must not be empty" (HTTP 400), wedging the session. Record each unexecuted call instead (arguments sanitized to {} when the truncated JSON is unparseable) and immediately close it with a synthetic interrupted error result. The exchange stays wire-valid, the history stays truthful, and the model learns the calls never ran so it can re-issue them.
…oonshotAI#1790) When the provider stream breaks off mid-tool-call (pause_turn, engine overload, token limit), the step ended with the partial tool call dropped silently: never executed, never recorded. If the response carried no other usable content, the persisted assistant message was effectively empty and every subsequent request — including compaction — was rejected with "assistant must not be empty" (HTTP 400), wedging the session. Record each unexecuted call instead (arguments sanitized to {} when the truncated JSON is unparseable) and immediately close it with a synthetic interrupted error result. The exchange stays wire-valid, the history stays truthful, and the model learns the calls never ran so it can re-issue them.
…oonshotAI#1790) When the provider stream breaks off mid-tool-call (pause_turn, engine overload, token limit), the step ended with the partial tool call dropped silently: never executed, never recorded. If the response carried no other usable content, the persisted assistant message was effectively empty and every subsequent request — including compaction — was rejected with "assistant must not be empty" (HTTP 400), wedging the session. Record each unexecuted call instead (arguments sanitized to {} when the truncated JSON is unparseable) and immediately close it with a synthetic interrupted error result. The exchange stays wire-valid, the history stays truthful, and the model learns the calls never ran so it can re-issue them.
Related Issue
No linked issue — the problem is explained below (from a real user session export).
Problem
A user session got wedged: every turn failed with HTTP 400
the message at position 35 with role 'assistant' must not be empty, and the compaction request failed the same way, so the session could not recover on its own.Root cause: when the provider stream breaks off mid-tool-call (Anthropic
pause_turn, engine overload, token limit), the loop dropped the partial tool call silently — never executed (correct), but also never recorded. The step persisted as an assistant message whose only content was an empty thinking block, which strict endpoints reject as an empty message on every subsequent request.What changed
paused/unknown/max_tokens) while the response still carries tool calls, each call is now recorded — id and name as received, arguments sanitized to{}when the truncated JSON is unparseable — and immediately closed with a synthetic interrupted error result. The calls are never executed; the exchange stays wire-valid; the model learns the calls never ran so it can re-issue them cleanly.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.