Studio: recall the latest version of a fact, not the most quotable one - #9161
Conversation
for more information, see https://pre-commit.ci
…feat/rolling-context-window
for more information, see https://pre-commit.ci
…feat/rolling-context-window
for more information, see https://pre-commit.ci
…feat/rolling-context-window
…window # Conflicts: # studio/backend/tests/test_gguf_completion_usage.py
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
for more information, see https://pre-commit.ci
Rolling context eviction currently drops turns with no way for a caller to learn which ones went. Extract the turn grouping so the eviction unit is reusable, add an identity-based diff of what a fit removed, and let a caller reserve room for content it intends to add back after fitting. The reserve deliberately does not affect whether trimming happens at all, only how far it goes once it is already required, so a conversation that fits today is still returned untouched.
An evicted turn is currently gone for the rest of the session, so the model will state the conversation began wherever its visible context begins. Keep the turns the rolling window drops in a searchable scope built on the existing store, chunker, embedder and hybrid retrieval. The archive is cumulative: every compaction adds to it and nothing is cleared, so a later compaction can still find what an earlier one evicted. It lives in its own scope rather than the thread's document scope, because thread documents are injected in full on every request and would re-inject the whole history. Idempotent by content hash, since the same turns are evicted again on every later request. Every entry point degrades to a no-op rather than raising.
Given only a search tool, a model decides for itself whether to look, and mostly does not. Measured on MRCR v2, a 35B declined on 56% of rows, scoring 0.099 when it skipped against 0.461 when it searched. Forcing one retrieval on the compaction turn took tool-only 0.258 to 0.604, and the model then called the tool on 0% of rows, so the common path costs nothing extra. Recall fires at most once per request, since the tool loop refits on every iteration. The tool loop renders it as an ordinary tool exchange through the builder shared with document auto-inject; the plain path prefixes the latest user message instead, because it sends no tools array and a tool role without one breaks strict chat templates. Only scalar counts join the context_truncated event, so no message content reaches the wire.
Forced recall answers the turn that evicted, but a later turn can refer to something the forced pass had no reason to fetch. Add search_conversation so the model can go looking, scoped to the thread's own archive and sharing the admission slot with document search so the two cannot race for the embedder. The tool is offered only once a thread has actually had turns archived, so an ordinary short chat never pays for the schema, and it is classified read-only so auto mode does not prompt on every call. A matching system-prompt note tells the model the session was compacted, since otherwise it assumes the conversation began where its visible context begins. Deleting a thread now drops its archive rather than leaking a scope per chat.
_select_request_tools also serves the token-count request model, which has no thread_id field, so the archive gate raised there.
Recalling your own conversation is mostly an exact-match problem: a name, a number, an identifier someone pasted twenty turns ago. Those live or die on rare-token matching, and hybrid fusion was losing them. Measured on a 30-turn walkthrough of a 230k-character document at a 16k window, where every turn shared the same wrapper text. The chunk holding the needle ranked 3rd lexically at any k, was never returned by dense retrieval at all, and RRF pushed it to 16th because it had 30 useless dense hits to fuse with. End to end the model answered with the exact code once lexical leads, and could not answer at all before. Dense still fills whatever the lexical pass leaves, for paraphrased recall.
Editing an earlier message rewinds a thread and continues down a new branch, but the archive is append-only and still holds everything the abandoned continuation produced. Verified against a live build: after rewinding past a turn, querying its distinctive text still returned it, so the model could be handed a turn that on this branch never happened. Recall now drops archived turns that are absent from the thread's saved transcript. Threads with no saved transcript are left unfiltered, since an API caller may pass a thread_id without persisting messages and an empty transcript is absence of evidence rather than evidence the turns are gone. Containment on a normalised prefix rather than a digest, because the archived copy is rendered from the inference projection and the saved copy comes back through the message store.
The only signal that a long chat had been compacted was a toast, which vanishes after a few seconds and does not survive a reload. A user who scrolls back later has no way to find out why the model seemed to forget the start of the conversation. The notice renders from metadata.custom.contextTruncation inside the assistant message's own container, so it is not part of the conversation sent to the model, is not editable, and is not exported as content, but it stays attached to the turn it describes. It reports how many messages were dropped and, when the conversation archive is on, that they are still searchable and how many passages were recalled.
… messages A chunk's leading overlap is the previous chunk's tail, and it can carry a whole short message with it, so resuming the scan at the message the previous chunk finished in could never match it and retired unedited turns as off-branch. The forward position is still tried first and the walk back stops at the previous chunk's own opening.
for more information, see https://pre-commit.ci
…ode like JSON.stringify Reasoning lives in reasoning_content on the wire and never in content, so forwarding the stored part list rendered a reasoning model's thinking where the request sends only the answer. And json.dumps escapes non-ASCII by default while JSON.stringify does not, so a multilingual tool result reconstructed differently from the archived copy. Both cost the turn its transcript seat.
Both turns can arrive in one compaction: the pre-check clears both before either is written, the shorter is written first, and the longer then met the re-check under the write lock and left the window at the shorter figure.
for more information, see https://pre-commit.ci
…ttempt The anchor rides in every truncation event and every assistant turn's metadata while the boundary stays sticky, so a large pasted message was copied across the thread; cap it at a head on both sides, where the read side already clamps only shallower. And a budget below one chunk is not no budget: CHUNK_TOKENS is a ceiling, not the size of a turn, so try one and let the exact recount reject it.
…path A rewind or a bigger window can put an evicted occurrence back in the prompt, and re-stamping a copy per transcript seat then left two byte-identical documents, so a recall slot went on text the model could already read.
|
@codex review |
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed69a308f3
ℹ️ 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".
| f"JOIN chunks c ON c.id=chunks_fts.chunk_id " | ||
| f"JOIN documents d ON d.id=c.document_id " | ||
| f"WHERE chunks_fts MATCH ? AND chunks_fts.scope IN ({placeholders}) " | ||
| f"ORDER BY s, d.archive_ordinal IS NULL, d.archive_ordinal DESC LIMIT ?" |
There was a problem hiding this comment.
Preserve legacy timestamp order in two-ended fetches
For archives created before archive_ordinal was added, every row has a NULL ordinal, so all ordering terms after s are constant in both the oldest-first and newest-first queries. When more than 128 legacy chunks share a lexical score, the two half-window fetches can therefore return the same arbitrary subset instead of opposite ends; _both_ends then deduplicates them, leaving later legacy revisions unreachable despite the 256-candidate strategy. Add created_at (and a stable chunk tie-break) in the appropriate direction for NULL ordinals.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced and fixed. On an archive whose ordinals are all NULL, every ordering term after the score was constant in both queries, so the two halves returned the identical subset: measured on 8 legacy chunks at k=3, oldest_first and newest_first came back with the same three chunk ids, and _both_ends deduplicates them into one end's worth of candidates.
Both statements now carry created_at in their own direction plus the chunk id as a stable final tie-break. Test asserts the two ends are different and share no rows; before the fix they were byte-identical.
Every ordinal is NULL there, so both halves ordered by nothing but the score and returned the same rows, which the merge then deduplicated into one end's worth of candidates. Order by created_at in each direction, with the chunk id as a stable last resort.
|
@codex review |
for more information, see https://pre-commit.ci
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Squashed onto main, so the files this branch and that PR both touch collide with our own copy of the same work. Resolved in favour of this branch at every marker, keeping main's unrelated changes inside those files.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d89c5af49e
ℹ️ 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".
There was a problem hiding this comment.
Preserve reasoning boundaries between tool-call batches
When a persisted assistant row contains tool-call, reasoning, then another tool-call, this pre-filter removes the only boundary between two tool rounds. The actual replay path in studio/frontend/src/features/chat/api/chat-adapter.ts:1291-1297 flushes pending tool calls when it encounters reasoning, whereas _as_wire now batches the calls together; group_turns consequently sees a different transcript shape, which can assign the later exchange an incorrect ordinal and cause branch validation to discard the archived turn. Omit the reasoning text while retaining its flush behavior.
Useful? React with 👍 / 👎.

Stacked on #9074. Review that one first; this PR's diff against it is the second commit only.
The problem
A long conversation makes its own subject the least discriminative word in its own archive.
Seeded a thread where one variable is assigned eight times and asked for its current value. The variable name appears in 8 of 17 archived chunks and scores 0.16 under BM25. An incidental word from the question, "value", appears in 1 chunk and scores 4.755. So the ranking is decided by the filler word, and a chunk about a retry budget outranks every chunk that actually names the variable.
Measured over 5 seeds: the newest assignment was archived 8 times out of 8, and retrieved 0 times out of 5. The information was in the store the whole time.
Three changes
Each is knob gated, and each off setting reproduces today byte for byte.
Selection.
conversation_match_queriesANDs identifier-like tokens first and falls back to a stopword stripped OR. It is used by the conversation archive only, so knowledge base, project and thread search produce identical bytes to today. Newest revision retrieved 5/5 at both four and eight revisions, with zero distractors.Order.
documentsgains a nullablearchive_ordinal, allocated inside the existing write lock and preserved across the re-embed path, so recalled turns are presented oldest first under a header saying that a later turn supersedes an earlier one. Retrieval that is right but presented newest first still reads as a contradiction. Asking what the value was originally still returns the first assignment, 5/5, so this does not simply bias everything to the newest.The governing instruction. A standing instruction is protected only while it is the newest user turn. A short follow up like "continue" makes that instruction the oldest eviction candidate, and the forced recall then searches the archive for the word "continue".
instruction_pincomputes a bounded pin through the existingprotected_message_idsseam, and a thin latest message adds the last substantive instruction as a second recall query. The anchor query arm went 2/5 to 5/5.Pinning ships off by default because it changes what the model is shown. The anchor query ships on because it changes only what is retrieved.
What it does not do
The pin is bounded newest first over a fixed number of groups, so it protects an instruction against filler, not against a genuinely long conversation. Enough later long user turns will still crowd it out. That limit has its own test rather than being left to be discovered later.
Tests
16 new tests across
test_conversation_archive.py,test_conversation_recall_injection.pyandtest_context_overflow_truncation.py, all of which fail against a pristine 285b1a7. Full backend suite is green relative to the base: the same 43 pre-existing failures appear on this branch and on 285b1a7, with an identical failure set.