fix(mental-models): don't overwrite content when reflect retrieved nothing - #3135
fix(mental-models): don't overwrite content when reflect retrieved nothing#3135chethanuk wants to merge 3 commits into
Conversation
…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.
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
left a comment
There was a problem hiding this comment.
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.
|
Confirmed and fixed in 99725e5. I reproduced your sequence first: the guard held for exactly one cycle, and refresh 3 cleared
|
Problem
refresh_mental_modelrefuses to write only when the rendered content is empty: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_factsskip does not cover this. It is gated onuse_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 deltamerge folds the previous
based_oninto the payload. Directives are excluded fromthat count: they are bank-config injection, not retrieval evidence, so a
based_oncarrying only directives is not grounding.The guard fires only when retrieval regressed:
prior_grounding_count > 0matters. A model that has never been grounded (freshseed content, no prior
reflect_response) has nothing to lose, so its firstrefresh 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
MentalModelRefreshErroris raised.The same counter also replaces
not supporting_factsin the delta no-new-factsskip, 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 didconfirm 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 thesame 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_modelviaexecute_task. The HTTP route enqueues and returns an operation id, so the newraise 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
-n0 -m "not hs_llm_core"The 12 deselected are
hs_llm_coretests, which CI runs in its own core-LLM job.New cases are deterministic (MockLLM and monkeypatch, no judge), parametrized over
empty
based_onwith existing content,based_oncarrying only directives, anever-grounded model whose first refresh must still write, and delta versus full
mode.