Skip to content

fix: Restore Tool Rounds Exactly as the Live Request Sent Them - #615

Open
Shabonasar wants to merge 9 commits into
LibreChat-AI:mainfrom
Shabonasar:fix/responses-tool-round-restore
Open

Shabonasar wants to merge 9 commits into
LibreChat-AI:mainfrom
Shabonasar:fix/responses-tool-round-restore

Conversation

@Shabonasar

@Shabonasar Shabonasar commented Oct 15, 2026 •

Copy link
Copy Markdown

Summary

A turn restored from stored content parts has to serialize like the live requests of that turn; otherwise the next turn stops being a prefix continuation at the first difference, and the provider cache writes everything after it again. Three things drift today. Sequential tool rounds of one turn are merged into one parallel batch. An OpenAI Responses tool round loses the provider items the live request carried (ids, status, reasoning items) and gains an empty assistant message. A tool result made of content blocks is restored as one string. This PR stores what is needed on the tool call part and restores it, so the restored request equals the live one in these cases.

Related to LibreChat-AI/LibreChat#17102 (tracking; design discussion LibreChat-AI/LibreChat#17481). Base: main, no dependency on other PRs. Hosts route the new ON_PROVIDER_OUTPUT_ITEMS event to aggregateContent and persist the parts unchanged.

How it works

live turn N:       assistant(fc_1, fc_2) tool tool assistant(fc_3) tool        ids, status, reasoning items
model response  -> graph slices the output per tool call -> ON_PROVIDER_OUTPUT_ITEMS -> tool_call.providerItems
ToolNode        -> tool_call.roundId (one per model response), outputFormat: 'blocks'
restored N+1:      formatAgentMessages(stored parts) = same assistant messages, same items, same tool result shape
stored on tool_call restores
roundId calls of one model response stay in one assistant message; a new round starts a new one; text emitted before a call stays with that call
providerItems { origin, created_at, items } Responses output items up to and including this call, put back as the assistant message's provider output
outputFormat: 'blocks' output holds the JSON of the content blocks and is parsed back into the same blocks
  • Replay gate for providerItems: only to the model and endpoint that produced them (origin digest), never under zdrEnabled, only reasoning, function_call and message items, under a size cap per call. A reasoning item without encrypted_content is left out once it is older than the retention of stored responses. A round restored without items falls back to the message fields, without an empty assistant message.
  • alignProviderItemsWithCall: when the stored call differs from the item the model produced, the item's function_call is rewritten to match the stored call, which is what the live request sent. This covers the combination with feat: Add a Fixed Tool Dispatcher for Mid-Conversation Tools #614, where a direct call to a deferred tool is stored as call_tool.

Type of change

  • Bug fix

Testing

Tested environments/configuration:

Automated tests:

npx jest src/messages src/llm src/graphs src/tools src/agents src/summarization
npx tsc --noEmit
  • This PR: 4805 passed. 3 suites fail because they need API keys (anthropic, google, vertexai llm.spec.ts).
  • formatAgentMessages.tools.test.ts: sequential rounds kept apart, stored Responses items put back, the stored call form replayed, a stored call kept when its args did not survive storage, nothing restored above the size cap, content blocks parsed back; replay gate for origin, ZDR and reasoning older than 29 days.
  • Graph.reasoning.test.ts: each call gets its slice of the stamped output and one round id per model response; the output is stamped with the model and endpoint. custom-chat-models.smoke.test.ts: origin stamp on the Responses client.
  • All five SDK PRs together: 6987 passed. Failing: the 3 suites above, plus durability-checkpoint.integration.test.ts and persistent-handoffs.test.ts, which need MongoDB and fail on main too.
  • LibreChat agent suites against all five together: packages/api src/agents 6913 passed (1 failure in handlers.shutdown.spec.ts, a shutdown timing test); api server/controllers/agents 1371 passed.

Risk / compatibility

  • Hosts that do not route ON_PROVIDER_OUTPUT_ITEMS get no providerItems and restore Responses rounds from the message fields as before, minus the empty assistant message. roundId and outputFormat ride on the existing completion events.
  • providerItems can hold encrypted reasoning. The host stores it like the other parts, and it is replayed only to its origin, never under ZDR.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published (none)

…Position

Adds conversation_instructions: context that is stable within a conversation.
It stays in the system message right after the static instructions and is never
moved: its own content block with a cache breakpoint for Anthropic and
OpenRouter, a block followed by a cache point for Bedrock, appended text for
other providers. additional_instructions keeps its per-turn behaviour.

A host that passes conversation_instructions gets the compaction summary, both
when created inside a run and when restored as initialSummary, as one stable
user message right after the system message, with a breakpoint of its own while
the request has one left. Token accounting counts it as instruction overhead.
Both paths render the same carrier text, so the requests after a compaction
form one continuous prefix. Hosts that do not pass the field keep the earlier
placement and accounting.

Adds a prompt cache harness (src/specs/prompt-cache) that runs declarative
scenarios through Run and each provider class, captures the serialized request
before the network, and asserts the cache invariants per provider.
A restored turn has to serialize like the live requests of that turn, or the
next turn stops being a prefix continuation at the first difference. Four
things drifted:

- Sequential tool rounds of one turn were merged into one parallel batch when
  restored. Rounds now carry a round id and restore as separate assistant
  messages; text emitted before a call stays with that call.
- A Responses tool round restored from stored parts lost the provider items
  the live request carried (function_call ids and status, reasoning items,
  message ids) and gained an empty assistant message. After a response with
  tool calls the graph slices the output per call and hands the slices to the
  new ON_PROVIDER_OUTPUT_ITEMS event; the content aggregator stores them on
  the tool_call parts and formatAgentMessages puts them back. They are replayed
  only to the model and endpoint that produced them, never under
  zdrEnabled, only when every item is a reasoning, function_call, or message
  item under a size cap, and reasoning references older than the retention of
  stored responses are left out. The replay gate takes the current time as an
  argument. Hosts must route the event to aggregateContent and persist the
  parts unchanged.
- An in-process tool result made of content blocks was stored as JSON and
  restored as one string. The completion now marks it with outputFormat
  'blocks' and restore parses it back into the same blocks.
- Anthropic sent a tool_result as a text block only while the tail cache marker
  sat on it, and OpenRouter with prompt caching did the same for tool
  messages. A single plain text tool_result is now always sent as its string
  (Anthropic), and every non-empty tool message as one text block (OpenRouter).

Tests: a generic live-versus-restored suite runs real turns against scripted
streams in each wire protocol and requires the first request of turn N+1 to
start byte for byte with the last request of turn N; Responses specs cover
the reasoning variants and the gate branches.
With prompt caching on Bedrock, additional_instructions was appended to the system
message after its last cachePoint. Bedrock reads tools, system and messages in that
order, so a tail that changes between turns sits in front of the whole history and
every message checkpoint misses.

The tail now moves to a message right before the newest user message, as for
Anthropic and OpenRouter, with one cachePoint at the end of the history in front of it
and one at the end of the request. The conversation block keeps its own cachePoint
only while the four-checkpoint limit leaves room for it (tools, stable block, history,
request end come first). Without prompt caching the tail stays in the system message.
…Marks Two Messages

The graph can place a second message checkpoint for the latest human message. Beside the
system checkpoints and the tool list that overran the limit of four in two places:
- the conversation block kept its own cachePoint only on the moved-tail path; the Bedrock
  system message now drops it whenever the checkpoints already in the messages leave no
  room, with or without a moved tail;
- the opening message of the history in front of a moved tail kept the graph's checkpoint
  on top of the two the tail path places; it is stripped now.
Sequential tool rounds, stored Responses output items and block tool results are restored as the live request carried them, so the next turn keeps the prefix.
@Shabonasar

Copy link
Copy Markdown
Author

Reduced to simplify dependencies and the review: based on main only, no dependency on other PRs (#613 no longer included), +3882 -> +996 lines.

No file changes (tree of main c56fc5e). Joins an earlier snapshot that the branches of
the series share (83eea26), so any two of them have a single merge base and merge cleanly.
Shabonasar added a commit to Shabonasar/LibreChat that referenced this pull request Oct 28, 2026
… tool rounds

OpenAI Responses tool rounds carry ids, status and encrypted reasoning that the
next turn has to send back unchanged. They are stored beside the message
(server-side only, capped per message) and put back on the restored tool calls,
so a restored round equals the request sent live. Active when the installed
agents SDK reports them (LibreChat-AI/agents#615); otherwise requests are built
as before.
@Shabonasar

Copy link
Copy Markdown
Author

Branch updated with a merge commit (no file changes) so that the agents PRs of LibreChat-AI/LibreChat#17102 merge together with a plain git merge; the PR diff is unchanged. All five together: integration/prompt-cache.

This branch has not been deployed

No deployments
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