Repository navigation
feat: Replay Signed Reasoning in Restored History - #619
Shabonasar wants to merge 20 commits into
Conversation
…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.
Rebased onto main and LibreChat-AI#615. The OpenAI Responses reasoning carrier (provider_replay.openai_responses) is not ported: on this base Responses reasoning is carried by providerItems (LibreChat-AI#615). The gpt-5.6 prefixed-model regex fix is kept. Co-authored-by: shars <32410570+Shabonasar@users.noreply.github.com>
Rebased onto main and LibreChat-AI#615. OpenAI Responses parts (snapshot dedup, Responses filtering) are not ported: Responses reasoning travels as providerItems on this base. Co-authored-by: shars <32410570+Shabonasar@users.noreply.github.com>
…tion Adapts the persisted provider reasoning from the previous two commits to the tool-round restore of LibreChat-AI#615. - Opt-in on both sides: AgentInputs.providerReasoningReplay turns capture on, formatAgentMessages({ replayProviderReasoning: { provider, model } }) turns restore on. Without them provider_replay is neither produced nor read. - Restore puts the signed blocks at the slot of their THINK part inside the round they belong to (before the text, after the text, between rounds) instead of at the start or end of the message; reasoning that resumes after text gets its own step so the aggregator keeps that position. - Capture records the model that served the call: the attempt stamp of the primary or fallback, then the model the graph resolved for the call (override model, modelName alias), then client options. - Recounts the stored token weight of messages that carry replay when the option is set, since the blocks are not part of it. - Anthropic requests with replay active carry the thinking binding beta and block_binding.prefix_mismatch_behavior=drop_block, and surface the top-level input_transformations of the response in response_metadata; nothing is sent otherwise. - Harness: live and restored requests keep earlier turns' thinking byte for byte (signed, redacted, empty text, interleaved, Anthropic and Bedrock), the replayed history is append-only, and the same scenarios without replay break the turn boundary. Model change and fallback scenarios are covered. - Only the contiguous tail after the last hole is replayed: a turn whose blocks are missing ends the chain, since the provider binds each block to the whole prefix before it. A summary resets the chain; the message that carries the summary keeps the blocks it produced after it. - Blocks whose prefix was rewritten inside a run (compaction, the retained tail after a summary, fading of old tool results) are dropped from the request and withdrawn from the host, so the next turn restores what was actually sent. The summarizer never receives signed reasoning. - Without the opt-in nothing changes: no thinking is injected, and the fallback and summarization filters stay off. Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
|
Merged current Checked: |
…ly signatures stream With the thinking display omitted a block streams no text, so the aggregator saw a later block only as a signature and appended it to the previous block's signature. The merged block was stored and replayed, the provider could not verify it and dropped it, which also rewrote the cached prefix. A signature closes its block and arrives whole, so a signature that follows a signed block now starts a new one, for Anthropic and Bedrock alike.
With thinking binding controls the provider reports in `input_transformations` the blocks it dropped from the history because the prefix they were signed under no longer matches. The field reached the response metadata only; a run could lose its signed reasoning without any trace. Emit a warning agent log with the transformations when a response carries any.
|
Two changes after @SwDevOp72's review in LibreChat-AI/LibreChat#17102, found by re-running the series on the real Anthropic API.
|
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.
…ponses-tool-round-restore
…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.
…ponses-tool-round-restore
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.
Streamed Anthropic and Bedrock reasoning keeps its signed blocks on the think part (`provider_replay`), and `formatAgentMessages` puts them back for the model that signed them. Anthropic asks the API to drop blocks that no longer match; Bedrock retries once without reasoning. Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
|
Reduced to simplify dependencies and the review: builds on #615 only, +7864 -> +1375 lines on top of #615 (+2371 with #615 until it merges).
|
…Context With endpoints.agents.promptCache.stableContext, the signed thinking blocks of Anthropic Messages API responses are stored beside the message and sent back in the restored history, to the model that signed them only, so the next turn starts with the bytes of the live request. Also through a reverse proxy with a custom base URL (it must forward the anthropic-beta header); not used through Vertex AI (a custom client) or Bedrock. Needs LibreChat-AI/agents#619. Based on LibreChat-AI/agents#371 by upman
|
Branch updated with a merge commit (no file changes) so that the agents PRs of LibreChat-AI/LibreChat#17102 merge together with a plain |
Summary
Claude models with thinking keep the thinking blocks of earlier turns in the prompt. A history restored from stored content parts has no signed blocks, so the next turn differs from the live request at the first thinking block of the previous turn, and the provider cache writes everything after it again. This is what rmesser measured in LibreChat-AI/LibreChat#17102. The miss happens on every turn where the model thought before calling a tool. This PR captures the signed blocks while streaming and, when the host asks for it, puts them back into the restored history at their original position. Turn N+1 then starts with the bytes of turn N.
Builds on the work of upman in #371 (persisting provider reasoning across runs, revalidation across model boundaries); his authorship is kept as co-author. The
gpt-5.6match inshouldIncludeEncryptedReasoning(Bedrock-hostedopenai.gpt-5.6-*ids) is his change, carried over from #371.Related to LibreChat-AI/LibreChat#17102 (tracking; design discussion LibreChat-AI/LibreChat#17481). Builds on #615 only. Opt-in: without
providerReasoningReplay/replayProviderReasoningnothing changes.How it works
AgentInputs.providerReasoningReplaythinking/redacted_thinkingand BedrockreasoningContenton thethinkpart, with the model that actually served the call (fallback included); reasoning that resumes after text keeps its own part, so blocks stay in orderformatAgentMessages(..., { replayProviderReasoning })thinking.block_binding.prefix_mismatch_behavior: "drop_block"and its beta headerthinking-binding-controls-2026-08-01, so a block that no longer matches is dropped by the API instead of failing the callType of change
Testing
Tested environments/configuration:
/api/agents/chat) with the LibreChat series from Tracking: stable prompt prefix cache across turns, conversations and providers LibreChat#17102 and all five SDK PRs (fix: Keep Per-Conversation Context and the Summary at a Fixed System Position #613, feat: Add a Fixed Tool Dispatcher for Mid-Conversation Tools #614, fix: Restore Tool Rounds Exactly as the Live Request Sent Them #615, feat: Replay Signed Reasoning in Restored History #619, fix(tools): persist tool output reference labels and keep keys unique across runs #629), cache TTL 1h, MCP tool calls with thinking.dev(earlier run, LibreChat-AI/LibreChat#17102)cache_controlmarkers and there were no API errors.Automated tests:
anthropic,google,vertexaillm.spec.ts).formatAgentMessages.test.ts: Anthropic and Bedrock blocks back before their tool call per round, only to the provider and model that signed them, unsigned blocks dropped, nothing replayed before a turn whose reasoning cannot be replayed, a new chain after a summary.stream.eagerEventExecution.test.ts,stream.test.ts,aggregator.test.ts: capture only with the opt-in, Anthropic signature-only and redacted parts, Bedrock reasoning,modelNamealias, attribution to the fallback that served the call, consecutive blocks kept apart.AgentContext.test.ts(client options per provider),Graph.contextOverflow.test.ts(fallback),Graph.reasoning.test.ts(reasoning after text in its own part),llm/anthropic/llm.spec.ts(drop_blockand beta only with enabled thinking),llm/bedrock/prefill.test.ts(one retry, only for a reasoning rejection).durability-checkpoint.integration.test.tsandpersistent-handoffs.test.ts, which need MongoDB and fail onmaintoo.packages/apisrc/agents6913 passed (1 failure inhandlers.shutdown.spec.ts, a shutdown timing test);apiserver/controllers/agents1371 passed.Risk / compatibility
block_bindingfield is sent only together with its beta header, and only with enabled thinking. A gateway between LibreChat and Anthropic has to pass both through.provider_replayholds signed reasoning. It goes back only to the provider and model that produced it.Checklist