Repository navigation
fix: Keep the Prompt Prefix Stable Across Turns and Conversations - #16369
Shabonasar wants to merge 9 commits into
Conversation
The per-request web search context (current date/time with milliseconds) was joined before additional_instructions, which carry mostly static text such as the artifacts prompt. Provider-side prefix caches stopped at the timestamp, so the static text after it was recomputed on every request. Move the dynamic context to the end via buildAdditionalInstructions and cover the ordering with unit tests.
|
Updated head |
…che-order # Conflicts: # packages/api/src/agents/initialize.ts
…e date changes in history The web search runtime context and temporal special variables were anchored to the turn start with millisecond precision. The system message precedes the whole history, so every turn invalidated the cached history and new conversations could not reuse the system prompt (LibreChat-AI#16877). - Web search runtime context states the conversation start in the user's timezone with minute precision; temporal variables resolve against the same persisted anchor. - A user message sent on a later local date than the model last saw gets a <context_update> block in its prompt copy only, derived from stored createdAt values so it renders identically on later turns; compaction boundaries follow the agents SDK formatter. - Optional endpoints.agents.promptMessageTimestamps adds the send time to every user message; the turn's user message is stored with createdAt = turnStartedAt on every save path. - Provider payload stability tests through createRun for OpenAI, an OpenAI-compatible endpoint, Anthropic, OpenRouter, Bedrock and Google.
… routes The browser resolved promptPrefix with its own clock on every send, so turn 1 of a spec-backed conversation carried the raw template while turn 2 carried resolved text in a different system-prompt position. Only Assistants routes resolve on the client now; the server resolves against the conversation start.
|
Updated head Reordering alone was not enough. Prefix caches still broke because the system message carried a per-turn timestamp (#15419), the client resolved Live measurement on Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through a LiteLLM proxy), 5-turn conversation growing to 29k tokens: cached prompt tokens on turn 5 went from 0 on Thanks @npdgm for the precise report and measurements in #16877, @dev-fbm for the Bedrock analysis, and @lia-by-librechat for the ordering and subagent commit kept here. Next, as a continuation: deterministic tool order (MCP servers and tools sorted by name), then delivering mid-conversation toggles and dynamically loaded MCP tools as history events, so dynamic loading never resets the cached prefix. |
|
Follow-up for the tools parameter: #17097 sorts MCP servers and their tools by name, so selecting the same servers in a different order, or a catalog returning tools in a different order, no longer changes |
… of a conversation
Adds `endpoints.agents.promptCache.stableContext` (default off). Off, the prompt is
built as before. On:
- prompt time (`{{current_datetime}}` and friends, the web search runtime context) is
anchored to the conversation start, and a date change is announced by a
`<context_update>` block at the start of the first user message sent after it;
- the conversation-stable part of `additional_instructions` is merged into the system
instructions for Anthropic and OpenRouter with prompt caching, while memory, RAG and
one-turn queued file context stay in `additional_instructions`, where the SDK moves
them next to the newest user message;
- dynamic tool context goes after the additional instructions;
- the user message is inserted with the turn start as `createdAt`, so later turns render
the same stamp.
`promptMessageTimestamps` (default off) additionally prefixes each user message with the
time it was sent.
The history notes, the instruction split and the lever live in `packages/api`; the
controller only calls them. `applyContextToAgent` accepts the run context as parts and
records which are volatile, so no text is split by matching.
|
Adding an independent Strata data point and a Docker Compose preload workaround for LibreChat v0.8.8 installations affected by #16877. I am running the date-only hook below. Keeping it in a host-mounted file avoids reapplying an edit inside the container after each image replacement. I checked the current PR head, Measured results LibreChat v0.8.8, local prefix-caching engine Strata, Qwen3.8-Flash-Next, agents on
Requests captured on the wire between LibreChat and the engine showed a 33,662-character system message that was byte-identical, with the same SHA-256, across consecutive user turns with the hook. The hook changes only the timestamp text; other request content is untouched. These are operational measurements at different prompt lengths, not a controlled identical-request benchmark. In these runs, reuse increased from 8,192 to 81,920–90,112 tokens and prefill dropped from 37.5–44.3 seconds to 4.4–7.8 seconds. The working hook In v0.8.8, Save as // websearch-date-stable.cjs — interim workaround for #16877
try {
const api = require('@librechat/api');
const orig = api.buildWebSearchDynamicContext;
const RE = /(Date(?: & Time)?:\s*)(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}[^\s]*/;
if (typeof orig !== 'function') {
console.warn('[patch] buildWebSearchDynamicContext not found, skipping');
} else if (orig.__dateStable !== true) {
let warned = false;
const wrapped = function (now) {
const out = orig.call(this, now);
if (typeof out !== 'string') return out;
const fixed = out.replace(RE, (m, label, date) => label + date);
if (fixed === out && /\d{2}:\d{2}:\d{2}/.test(out) && !warned) {
warned = true;
console.warn('[patch] volatile time-of-day text present but unmatched: upstream format changed');
}
return fixed;
};
wrapped.__dateStable = true;
api.buildWebSearchDynamicContext = wrapped;
console.warn('[patch] web_search runtime context trimmed to date-only');
}
} catch (err) {
console.warn('[patch] did not apply:', err && err.message);
}Docker Compose deployment Merge these entries under the LibreChat service in environment:
- NODE_OPTIONS=--require /app/patches/websearch-date-stable.cjs
volumes:
- type: bind
source: ./patches
target: /app/patches
read_only: trueThe hook's log line can appear once per Node process ( No file in the image is modified. The host file and Compose override persist across image replacements and are picked up when the service is recreated with Scope and caveats
This is a temporary mitigation for the volatile web-search timestamp, complementing the date-only local patch reported in #16877. It does not fix other dynamic instructions, tool-schema changes, or the broader causes of prefix instability tracked in #17102. |
|
Updated the branch:
@dlbogdan thank you for the independent numbers in #16369 (comment): 8,192 -> 81,920-90,112 reused tokens and 37-44 s -> 4-8 s prefill with only the web search time trimmed confirms that the timestamp alone costs most of the prefix on long conversations. One note for anyone using that preload: drop it when moving to a build with this PR. Here |
Summary
Provider prompt caches reuse a request only up to its first changed byte. Since #15419 the system prompt carries a per-turn timestamp, and with Anthropic or OpenRouter prompt caching the dynamic system tail is moved before the last user message and shifts every turn, so long conversations and many conversations of one agent lose most of the cache (#16877). This PR adds
endpoints.agents.promptCache.stableContext(defaultfalse, which keeps today's behaviour). With it on, prompt time is anchored to the conversation start, a later date is announced by a<context_update>block at the start of the first user message sent after it, static instructions precede per-chat context, and the per-turn parts of the tail stay out of the stable instructions.Related to #17102, #16877, #12907, #15419, #16563. Base:
dev. First PR of the stack in #17102.How it works
{{current_datetime}}and friends, and the web search runtime context, resolve against the conversation start (conversationCreatedAt, minute precision, user timezone). The client no longer resolvespromptPrefixwith the browser clock for Agents routes.createdAtand the conversation anchor), so it renders identically on every later turn. After a compaction the comparison restarts from the conversation start date.createdAt = turnStartedAt.endpoints.agents.promptMessageTimestamps(defaultfalse) adds the send time to each user message.packages/api(contextUpdates.ts,instructionPlan.ts,promptCache.ts); the controller only calls them.Measurements
Run: group G1 measured on Qwen3.8-27B (vLLM behind NVIDIA Dynamo, through LiteLLM), OpenAI chat and Anthropic Messages, dev / lever off / lever on; the same self-hosted model answers both formats, the real OpenAI and Anthropic APIs were not measured; full method in #17102. This PR is the part of the stack that moves G1.
Note: the backend caches in 1600-token blocks, so a request reuses cache only in whole blocks and the first turns of a short conversation (prompt under two blocks) cannot hit; shares are therefore lower on short chats than on long ones.
Verification
npm run lighthouse(3 cold navigations per run, Mongo delay 250 ms per query),devagainst the head of the stack with the lever off and on; ranges over the valid runs.All results are within the CI budgets and the differences are within run-to-run noise; the lever only adds
+contextAnchorto an existing select, so message loading does the same queries in all three configurations.Type of change
Testing
Enable the lever in
librechat.yaml, run a multi-turn Agents conversation across a pause, and compare the cached tokens of consecutive requests:Automated tests: the test files this PR adds or changes, run per workspace with
npx jest <files>(mongod-backed suites against a real mongod): packages/api 594, packages/data-provider 191, api 658 tests passing. Files:BaseClient.test.js,request.resumeMetadata.spec.js,client.test.js,initialize.test.ts,promptPrefixStability.test.ts,run-summarization.test.ts,contextUpdates.boundary.spec.ts,contextUpdates.spec.ts,instructionPlan.spec.ts,run.spec.ts,web.spec.ts,config-schemas.spec.ts.Static checks:
tsc --noEmitin packages/api, packages/data-provider; eslint and prettier on the touched files.Screenshots / recordings
No user-facing change.
Risk / compatibility
Default off reproduces today's requests. A user who switches timezones mid-conversation re-renders earlier blocks once (one cache miss).
Checklist
endpoints.agents.promptCache.stableContextandpromptMessageTimestamps, after the review settles the setting names