Studio: recall the latest version of a fact, not the most quotable one by danielhanchen · Pull Request #9161 · unslothai/unsloth · GitHub
Skip to content

Studio: recall the latest version of a fact, not the most quotable one - #9161

Merged
danielhanchen merged 190 commits into
mainfrom
feat/conversation-recency
Aug 20, 2026
Merged

Studio: recall the latest version of a fact, not the most quotable one#9161
danielhanchen merged 190 commits into
mainfrom
feat/conversation-recency

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

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_queries ANDs 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. documents gains a nullable archive_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_pin computes a bounded pin through the existing protected_message_ids seam, 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.py and test_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.

alkinun and others added 30 commits August 16, 2026 09:24
…window

# Conflicts:
#	studio/backend/tests/test_gguf_completion_usage.py
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.
danielhanchen and others added 5 commits August 19, 2026 21:54
… 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.
…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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

danielhanchen and others added 8 commits August 19, 2026 22:19
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.
…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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread studio/backend/core/rag/store.py Outdated
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 ?"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: ab1bf4330d

ℹ️ 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".

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +900 to +904

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants