Skip to content

fix(refresh): preserve mental model content when reflect returns no usable answer - #2960

Open
gvilleg wants to merge 2 commits into
vectorize-io:mainfrom
gvilleg:fix/refresh-preserve-on-empty-answer
Open

fix(refresh): preserve mental model content when reflect returns no usable answer#2960
gvilleg wants to merge 2 commits into
vectorize-io:mainfrom
gvilleg:fix/refresh-preserve-on-empty-answer

Conversation

@gvilleg

@gvilleg gvilleg commented Jul 25, 2026

Copy link
Copy Markdown

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, so refresh_mental_model's
"refuse to overwrite with an empty render" guard — which tests not final_content.strip() — does not
fire, 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 MentalModelRefreshError is 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 — a
required 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_tool computes it before substituting the placeholder.

No default is deliberate: a future terminal path that forgets the field raises ValidationError
rather than silently reporting success. Placeholder strings moved to reflect/models.py (with
agent.py re-exporting NO_ANSWER_TEXT for existing importers) so persistence code can import them
without pulling in the agent module.

Check it before delta (engine/memory_engine.py)

refresh_mental_model now inspects answer_failure_reason immediately after reflect and before any
delta 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 full based_on evidence and accumulated delta provenance — with
refresh_skipped set to the specific reason, leaves content/structured_content untouched, and
raises.

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, and ITERATION_LIMIT_TEXT. The text-level guard now uses it instead of a bare
emptiness test, and _write_refresh_outcome_metadata uses the same helper, so what gets stored and
what 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 in
depth.

Failed refreshes no longer advance query tracking (engine/memory_engine.py)

Both failure paths previously passed last_refreshed_source_query=current_source_query while
deliberately preserving old content. Since use_delta requires the stored query to match the current
one, 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_facts path still advance tracking, since in both the stored
content really is current for that query.

Behavior change

A refresh that produces no usable answer now raises MentalModelRefreshError instead of completing.
MentalModelRefreshError is already classified retryable and converts to RetryTaskAt, so async
refreshes 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 patch
reflect_async rather than refresh_mental_model, so the real persistence path — delta, guards, the
write — actually executes; stubbing the refresh itself would assert nothing about storage.

  • blank answer preserves content, raises, records refresh_skipped == "empty_answer"
  • iteration-limit preserves content, raises, records refresh_skipped == "iteration_limit"
  • a failed refresh preserves its based_on evidence
  • delta mode cannot launder a failed answer: asserts delta was actually selected (created_after
    present) and that no delta operation was emitted
  • a failed refresh does not advance source-query tracking, so its retry is still a full regeneration
  • a successful refresh still writes content
  • is_populated_content unit coverage for all three placeholders

Each 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_query on a failure write fails the tracking test; disabling the early check
fails 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.py passes unchanged: its no-answer case stubs
refresh_mental_model entirely, so it exercises only the metadata writer, where populated_content=False
remains correct.

tests/test_refresh_preserves_on_failed_answer.py  tests/test_mental_model_delta.py  tests/test_refresh_outcome_metadata.py
21 passed, 4 skipped

scripts/hooks/lint.sh reports no diagnostics in any changed file.

Notes for reviewers

  • The trigger — why a large synthesis returns a blank done.answer in the first place — is
    environment-specific and not addressed here. This change is about what happens when it does:
    a failed render must not destroy a working document.
  • Sequencing is the crux of the fix. populated_content on main already recognizes the
    placeholder, but it is computed after refresh_mental_model has persisted and returned, so it can
    only annotate the loss. The check has to happen before delta and before the write.

gvilleg added 2 commits July 25, 2026 01:20
…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.
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 a healthy mental model with the "No answer provided." placeholder when reflect fails

1 participant