fix(refresh): preserve mental model content when reflect returns no usable answer - #2960
Open
gvilleg wants to merge 2 commits into
Open
fix(refresh): preserve mental model content when reflect returns no usable answer#2960gvilleg wants to merge 2 commits into
gvilleg wants to merge 2 commits into
Conversation
…swer
refresh_mental_model could replace a working mental model with a one-line
placeholder while reporting success.
The reflect agent substitutes human-readable text when it cannot produce an
answer: NO_ANSWER_TEXT ("No answer provided.") when the model returns nothing,
and an iteration-limit message when the agent runs out of iterations. Both are
non-empty, so the "refuse to overwrite existing content with an empty render"
guard in refresh_mental_model did not fire, and the placeholder was stored over
the document. The operation completed without error, so nothing surfaced the
loss; populated_content=false was recorded afterwards, but only as metadata on
an already-overwritten model.
The fix is a machine-readable signal rather than another text check:
- ReflectAgentResult.answer_failure_reason ("empty_answer" | "iteration_limit" |
None) is set at each terminal path in the agent, where the condition is known.
It is REQUIRED, with no default, so a terminal path added later that forgets
to set it fails validation instead of silently reporting success.
- refresh_mental_model checks it immediately after reflect and BEFORE delta
rendering, then preserves the previous content and raises. Order matters:
delta can render a placeholder candidate into plausible-looking markdown, so a
check placed after it would see ordinary content.
- The failure record still stores this refresh's based_on and any accumulated
delta provenance, so the failure is auditable and the next refresh does not
start from an erased grounding set. Only content and structured_content are
left untouched.
- is_populated_content() centralizes the placeholder check (empty, pending
placeholder, both fallback answers). The persistence guard and the refresh
outcome metadata now share it, so what is reported and what is stored cannot
drift apart.
MentalModelRefreshError is retryable, so the task layer re-raises it as
RetryTaskAt and only exhausted retries settle as failed. Preservation is what
makes retrying safe: each attempt leaves the existing document intact.
tests/test_refresh_preserves_on_failed_answer.py covers both failure texts,
evidence preservation, the delta path, the async/worker path, and a success
canary. The tests patch reflect_async rather than refresh_mental_model so the
real persistence path executes. Verified against pre-fix code: 5 of the 7 fail
without the change, and the pre-existing test_mental_models.py errors are
unrelated (they require live LLM credentials and occur on an unmodified tree).
Review of the previous commit found that both failed-refresh audit writes still passed last_refreshed_source_query=current_source_query while deliberately preserving the old content, which corrupts the retry. Delta mode requires an unchanged source query, so changing a delta model's query correctly forces a full regeneration: the stored document is about the previous topic. But if that attempt failed, the audit write recorded the new query anyway. The retry then saw an unchanged query, selected delta, and passed created_after — amending the old topic's document from recall scoped to new memories only. Content was preserved on every individual attempt, yet a later successful retry could persist mixed-topic content. Both failure paths (the answer_failure_reason guard and the empty-candidate guard) now write only reflect_response. Nothing was synthesized for the current query, so no tracking state should say otherwise. The success path and the delta no_new_facts path still advance it, since in both cases the stored content really is current for that query. Also repairs the delta regression, which never enabled delta mode: the fixture omitted trigger.mode and refresh_mental_model defaults to "full", so the test performed two full refreshes and its name claimed a path it never exercised. There is now a delta fixture, the delta LLM call is instrumented, and the test asserts both that delta was actually selected (created_after present) and that the failed answer exits before any delta operation is emitted. The canned operation adds an ordinary prose section, so with the early guard removed delta produces normal-looking markdown — the laundering the guard exists to prevent. Verified by mutation: restoring the query-tracking write fails the new tracking test; disabling the early guard fails the delta test. Focused and related suites: 21 passed, 4 skipped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #2959.
When the reflect agent finishes without a usable answer it substitutes a human-readable placeholder
(
NO_ANSWER_TEXT, or the iteration-limit text). Both are non-empty, sorefresh_mental_model's"refuse to overwrite with an empty render" guard — which tests
not final_content.strip()— does notfire, and the placeholder is written over the existing document while the operation reports success.
This makes the refresh fail closed: when reflect produced nothing usable, the existing content stays,
the failure is auditable, and
MentalModelRefreshErroris raised.Changes
A required failure reason at the source (
engine/reflect/models.py,engine/reflect/agent.py)ReflectAgentResult.answer_failure_reason: Literal["empty_answer", "iteration_limit"] | None— arequired field with no default, supplied at all seven terminal construction sites. Each site already
knows whether it produced an answer, so the signal is recorded where the fact is known rather than
inferred later from text.
_process_done_toolcomputes it before substituting the placeholder.No default is deliberate: a future terminal path that forgets the field raises
ValidationErrorrather than silently reporting success. Placeholder strings moved to
reflect/models.py(withagent.pyre-exportingNO_ANSWER_TEXTfor existing importers) so persistence code can import themwithout pulling in the agent module.
Check it before delta (
engine/memory_engine.py)refresh_mental_modelnow inspectsanswer_failure_reasonimmediately after reflect and before anydelta work. Placement matters: delta applies operations to the stored document and re-renders it, so
a guard after that step would inspect ordinary-looking markdown and pass. On failure it persists the
reflect_response— including the fullbased_onevidence and accumulated delta provenance — withrefresh_skippedset to the specific reason, leavescontent/structured_contentuntouched, andraises.
One predicate for storing and reporting (
engine/memory_engine.py)New pure helper
is_populated_content(content)rejects empty/whitespace,MENTAL_MODEL_PENDING_CONTENT,NO_ANSWER_TEXT, andITERATION_LIMIT_TEXT. The text-level guard now uses it instead of a bareemptiness test, and
_write_refresh_outcome_metadatauses the same helper, so what gets stored andwhat gets reported cannot drift. (This also gives the outcome metadata iteration-limit coverage it
did not previously have.) The authoritative signal is
answer_failure_reason; this is defense indepth.
Failed refreshes no longer advance query tracking (
engine/memory_engine.py)Both failure paths previously passed
last_refreshed_source_query=current_source_querywhiledeliberately preserving old content. Since
use_deltarequires the stored query to match the currentone, that combination corrupts the retry: after a source-query change, a failed full regeneration
would record the new query, and the retry would then select delta and amend the previous topic's
document from recall scoped to new memories only. Failure paths now persist only the audit payload.
The success path and the delta
no_new_factspath still advance tracking, since in both the storedcontent really is current for that query.
Behavior change
A refresh that produces no usable answer now raises
MentalModelRefreshErrorinstead of completing.MentalModelRefreshErroris already classified retryable and converts toRetryTaskAt, so asyncrefreshes retry as before and settle terminal only after exhaustion. Retry is safe precisely because
each attempt is now non-destructive — the risk was never the retry, it was the overwrite.
Tests
New:
hindsight-api-slim/tests/test_refresh_preserves_on_failed_answer.py(8 cases). They patchreflect_asyncrather thanrefresh_mental_model, so the real persistence path — delta, guards, thewrite — actually executes; stubbing the refresh itself would assert nothing about storage.
refresh_skipped == "empty_answer"refresh_skipped == "iteration_limit"based_onevidencecreated_afterpresent) and that no delta operation was emitted
is_populated_contentunit coverage for all three placeholdersEach was mutation-checked against the pre-fix behavior: reverting the guards fails 5 of 7; disabling
only the early check still fails 3 (the text guard catches the rest); restoring
last_refreshed_source_queryon a failure write fails the tracking test; disabling the early checkfails the delta test with real laundering — delta runs, renders ordinary prose, and no error is
raised. A test that passes with and without the fix would prove nothing, so this was verified rather
than assumed.
tests/test_refresh_outcome_metadata.pypasses unchanged: its no-answer case stubsrefresh_mental_modelentirely, so it exercises only the metadata writer, wherepopulated_content=Falseremains correct.
scripts/hooks/lint.shreports no diagnostics in any changed file.Notes for reviewers
done.answerin the first place — isenvironment-specific and not addressed here. This change is about what happens when it does:
a failed render must not destroy a working document.
populated_contentonmainalready recognizes theplaceholder, but it is computed after
refresh_mental_modelhas persisted and returned, so it canonly annotate the loss. The check has to happen before delta and before the write.