Skip to content

fix(mental-models): don't overwrite content when reflect retrieved nothing - #3135

Open
chethanuk wants to merge 3 commits into
vectorize-io:mainfrom
chethanuk:wf/issue-2894-mm-refresh-empty-guard
Open

fix(mental-models): don't overwrite content when reflect retrieved nothing#3135
chethanuk wants to merge 3 commits into
vectorize-io:mainfrom
chethanuk:wf/issue-2894-mm-refresh-empty-guard

Conversation

@chethanuk

Copy link
Copy Markdown
Contributor

Problem

refresh_mental_model refuses to write only when the rendered content is empty:

if not final_content.strip():

A reflect run that retrieves nothing does not produce an empty answer. The agent
answers from the prompt alone, with a non-empty generic refusal along the lines of
"I don't have information about that", which passes that check and replaces a good
mental model with garbage. In delta mode it is worse than a one-off: the refusal
text becomes the baseline the next refresh builds on.

The existing no_new_facts skip does not cover this. It is gated on
use_delta and current_doc is not None, so full mode has no protection at all,
and full is the default.

Fixes #2894

Fix

Count the grounding reflect actually retrieved, from based_on, before the delta
merge folds the previous based_on into the payload. Directives are excluded from
that count: they are bank-config injection, not retrieval evidence, so a
based_on carrying only directives is not grounding.

The guard fires only when retrieval regressed:

fresh_grounding_count == 0 and prior_grounding_count > 0 and has_delta_baseline

prior_grounding_count > 0 matters. A model that has never been grounded (fresh
seed content, no prior reflect_response) has nothing to lose, so its first
refresh still writes normally. Only a model that had grounding and now has none is
protected.

On that path the previous content and structured payload are left untouched, the
failure is recorded as reflect_response.refresh_skipped = 'no_memories_found'
alongside the source-query tracking, and MentalModelRefreshError is raised.

The same counter also replaces not supporting_facts in the delta no-new-facts
skip, which until now let directives alone count as new evidence.

What this is and is not

This is defense in depth against an empty recall from any cause. It is not a fix
for the consolidation or embedding-index race the reporter suspected. Sanderhoff-alt
could not reproduce that race: he dropped all three bank HNSW indexes and held
ACCESS EXCLUSIVE on memory_units, and the overwrite never happened. He did
confirm the defensive gap itself, via a mismatched strict tag scope. PR #2997 was
closed as not reproducible for claiming the former, so this claims only the latter.

#2959 covers a sibling case, the "No answer provided." placeholder taking the
same path. That is deliberately not touched here; PR #2960 owns it, and keeping
the two apart keeps both diffs composable.

Behaviour change worth flagging

Refreshes that previously "succeeded" while writing a refusal will now raise and
surface as failed operations. That is the point, but it is visible: bank operators
who were silently accumulating garbage documents will start seeing failures
instead. The alternative, returning the previous content quietly, would hide
upstream retrieval failures from workers and tests.

Only one internal caller is affected, _handle_refresh_mental_model via
execute_task. The HTTP route enqueues and returns an operation id, so the new
raise cannot become a user-facing 500.

Merged PR #3078 named this issue as a blocker on widening auto-refresh to
strict-tagged models.

Test evidence

Check Result
mental-model suite, -n0 -m "not hs_llm_core" 95 passed, 12 deselected, 111s

The 12 deselected are hs_llm_core tests, which CI runs in its own core-LLM job.

New cases are deterministic (MockLLM and monkeypatch, no judge), parametrized over
empty based_on with existing content, based_on carrying only directives, a
never-grounded model whose first refresh must still write, and delta versus full
mode.

…thing

refresh_mental_model only refused to write when the rendered content was
empty. A reflect run that retrieves no memories does not produce an empty
answer: the agent answers from the prompt alone with a non-empty generic
refusal ("I don't have information about that"), which sails past the empty
check and replaces a good document with garbage. In delta mode that refusal
then becomes the anchor for the next refresh.

Grounding is now counted from based_on before the delta merge folds the
previous based_on into the payload. Directives are excluded: they are
bank-config injection, not retrieval evidence, so a directives-only based_on
is not grounding.

The new guard fires only when retrieval regressed -- fresh grounding is zero
AND the model was previously grounded AND a delta baseline exists. A model
that has never been grounded (fresh seed content) has nothing to lose, so its
first refresh still writes. Previous content is preserved and the failure is
audited via reflect_response.refresh_skipped = 'no_memories_found', then
MentalModelRefreshError is raised so callers do not read a silent no-op as
success.

The same counter replaces `not supporting_facts` in the delta no-new-facts
skip, which previously let directives alone count as new evidence.
CI's verify-generated-files job runs generate-docs-skill.sh and fails on any
diff. vectorize-io#3109 added last_write_at without regenerating this file, so the job is
red on main and on every open PR. Mechanical regen only.

Duplicates the second commit on vectorize-io#3120; drop whichever lands second.
chethanuk added a commit to chethanuk/hindsight that referenced this pull request Aug 1, 2026
CI's verify-generated-files job runs generate-docs-skill.sh and fails on any
diff. vectorize-io#3109 added last_write_at without regenerating this file, so the job is
red on main and on every open PR. Mechanical regen only.

Duplicates the same commit on vectorize-io#3120 and vectorize-io#3135; drop whichever lands last.

@ebarkhordar ebarkhordar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Full mode loses this guard after one cycle, because the skip path clears the counter the guard depends on.

prior_grounding_count is read from the stored reflect_response.based_on (memory_engine.py:11768), and the skip path persists reflect_response_payload wholesale (:11941). In full mode that payload holds this run's based_on only: the accumulate-with-previous merge at :11773 sits inside if use_delta:. So the first ungrounded refresh preserves content and writes back a based_on with zero grounding, and the next identical refresh reads prior_grounding_count == 0, fails the > 0 clause at :11929, and writes the refusal.

Measured at d46f682, full mode (no trigger.mode), reflect_async stubbed to return the refusal text with an empty based_on, stub embeddings, real Postgres:

REFRESH 1 (grounded, 1 observation): no raise
  content='# Team\n\nAlice is the lead.\n'   stored grounding=1  refresh_skipped=None

REFRESH 2 (retrieval empty, refusal text): RAISED MentalModelRefreshError
  content='# Team\n\nAlice is the lead.\n'   stored grounding=0  refresh_skipped='no_memories_found'

REFRESH 3 (identical conditions to refresh 2): NO RAISE
  content="I don't have information about this topic."   stored grounding=0  refresh_skipped=None

Refresh 3 also resets refresh_skipped to None, so the audit record of the first failure goes out with the document.

Delta mode is not affected. The same sequence with trigger={"mode": "delta"} returns early at :11838 and the merge keeps the accumulated based_on, so grounding stays at 1 through refreshes 2, 3 and 4.

Worker-driven refreshes retry, so the gap between the two runs is just the retry interval. One way to close it: on the skip path, carry the previous based_on into the persisted payload rather than replacing it, which is what :11773-11782 already does in delta mode. Not blocking if you would rather the audit payload stay a snapshot of the failed run, though then the counter likely needs to read from something the skip path does not rewrite.

The skip path replaced reflect_response with the current run's payload, and
prior_grounding_count reads its based_on. update_mental_model writes the column
wholesale, so a skipped refresh zeroed out the very counter the guard depends
on: cycle N preserved the document but stored an empty based_on, and cycle N+1
saw a prior count of 0, let the refusal through, and cleared the refresh_skipped
audit marker with it. Delta mode hid this because its merge re-accumulates the
previous based_on before that point.

The skip path now records only the failure marker and carries the stored
reflect_response forward. Content is preserved too, so text/based_on still
describe the document that is actually stored; the failed run is reported by
the raise and the log line.

Regression test does three consecutive skipped refreshes in full mode and
asserts both the content and the marker survive each one. It fails without the
engine change.
@chethanuk

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 99725e5. I reproduced your sequence first: the guard held for exactly one cycle, and refresh 3 cleared refresh_skipped along with the content. Also:

  • The skip path now writes only the failure marker and carries the stored reflect_response forward, i.e. your first option. It reads as the honest record rather than a compromise, since content is preserved on this path too, so text and based_on still describe the document that is actually stored. A later successful refresh writes a fresh payload and clears the marker.
  • I tried your second option first, keying on stored content. Durable by construction, but too broad: content cannot tell a retrieval-built document from hand-authored seed text, so a model grounded only in directives or bank background context could never refresh again. It broke two unrelated plumbing tests. It would also need to exclude NO_ANSWER_TEXT or it strands any model already holding the refresh_mental_model overwrites a healthy mental model with the "No answer provided." placeholder when reflect fails #2959 stub.
  • Regression test test_full_refresh_guard_survives_consecutive_skips: three consecutive skipped refreshes in full mode, asserting content and marker survive each. Verified it fails on the previous commit. No existing test refreshed after a skip, which is how this got through.
  • Left alone: the skip path still passes last_refreshed_source_query. fix(refresh): preserve mental model content when reflect returns no usable answer #2960 removes that from both failure paths for its own reason, and it is pre-existing on main rather than new here, so I would rather not collide with it.

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.

refresh_mental_model overwrites existing content when based_on is empty (consolidation race condition)

2 participants