memory: never report a failed recall as "no relevant memories" - #260
memory: never report a failed recall as "no relevant memories"#260oranjeai wants to merge 1 commit into
Conversation
A memory_recall whose embedding call fails at the connection layer -- DNS, refused, reset, or a transport closed under it by an LLM-client reset -- rendered as "Recalled 0 memories" / "No relevant memories found.". A session then concludes memory is empty, skips its recall obligation and redoes work: the confident wrong answer MemoryBackendUnavailable was introduced to prevent (835d857). Not theoretical. "Connection error." appears 42 times in this instance's 1.95 GB nerve.log (measured 2026-08-03 21:10 UTC; rolling), every one on the memorize/chat side and 0 on "memU recall failed" -- so the exception class occurs routinely in production and recall has simply not been the unlucky caller yet. Unexercised, not latent. Root cause: recall()'s classification enumerated transient *signatures* instead of partitioning failure *kinds*. Its only term, _is_transient_llm_error, keys on an HTTP status (429/5xx, plus auth on 401/403), so a call that fails at the connection layer carries no status and cannot match it by construction; it fell through to `return []` alongside genuine logic errors. Nothing normalizes it on the way up either: memu/app/retrieve.py:42 retrieve has 0 except arms (verified by AST), so the raw SDK exception reaches nerve's handler unwrapped. The tell that this is an inconsistent cut across the transport hierarchies rather than a missing exception type: openai.APITimeoutError is a subclass of openai.APIConnectionError, and the code already treated that as backend-down. It accepted a proper subset of the hierarchy and rejected the rest of it. The fix makes `[]` from recall() mean "retrieval returned nothing", for every failure recall() can observe: * a new _is_llm_transport_failure tests each SDK's APIConnectionError base class. Both openai and anthropic are needed and neither subsumes the other (measured: issubclass(anthropic.APIConnectionError, openai.APIConnectionError) is False), because memU's own client raises the former while nerve's _BedrockLLMClient raises the latter. Imports are guarded so the module stays importable without a given provider, and the guard wraps only the import, never the isinstance -- a predicate bug must surface, not degrade to []. * infrastructure failures raise MemoryBackendUnavailable; everything else propagates to the caller's pre-existing generic error path instead of being swallowed. All three callers already catch, so the inversion cannot escape unhandled. * the uninitialized-bridge guard raises instead of returning [] -- the same lie by a different route, with no exception involved at all. * the tool message drops its false cause attributions -- "transient proxy/auth error" and the closing "memory is down" -- for outcome-only wording, since the same arm covers 429s, which *are* responses, and both a self-closed local transport and a bridge that never initialized leave the remote blameless. The class docstring is likewise rewritten around the outcome: its LLM-only enumeration was already false, because memorize_file raises the same type for SQLite write-lock contention. A genuine miss still returns [], pinned by a test, else the fix would make every miss look like an outage. engine.py's pre-recall additionally stops freezing a failed recall into session metadata, where it was replayed on every rebuild. Scoped deliberately to recall(). memorize_file, update_item and the two category paths keep their current returns: their tool layers already render an explicit failure, so they produce no confident wrong *answer*, and a write reporting failure is honest -- only a read reporting emptiness lies. Of the 50 broad except arms in this file, exactly two read paths still return [] after this change (list_items, list_categories), and both are non-network-bearing, verified rather than assumed: within those two callee function bodies there are 0 embed/llm/client references (the claim is function-scoped; whole-file crud.py has many), against 5 embed_client.embed call sites in retrieve. The invariant is stated scoped, not global: no failure that *reaches* MemUBridge.recall is reported as []. It is deliberately not "[] always means a successful miss", because memU absorbs a malformed LLM ranking response below this arm (memu/app/retrieve.py:1341-1347 wraps the parse in `except Exception -> logger.warning` and returns an empty list; memu-py==1.4.0 is pinned), so a garbled ranker reply still yields a legitimate empty recall with no exception reaching us. Closing that is a memU-layer change. Tests: 15 new arms. Both directions -- base source with the new tests kept gives 14 failed / 1 passed (the 1 is the genuine-miss control, which must hold on both trees), the fix gives 15 passed. Two arms drive the real production callers rather than a reconstruction: the engine arm calls _get_or_create_client and asserts the persisted session metadata, so the freezing claim above is pinned by the actual DB write, and the session_context arm asserts its own handler renders the error. A 17-mutant matrix kills every mutant with the unmutated control green at both ends, including the two that discriminate under- from over-classification (restoring `return []` vs raising MemoryBackendUnavailable for a ValueError), and the one that narrows the predicate to a __cause__-message check, which fixes the 0-occurrence closed-transport sub-case while leaving the 42-occurrence one broken. The two mutants covering the real callers were each measured to survive the earlier, reconstructed test shape and to be killed by the current one, so the coverage those two arms add is demonstrated rather than asserted. Three further mutants were measured the same way: one that skips the engine's pre-recall branch entirely, one that returns [] without ever calling retrieve, and one that re-asserts the "memory is down" cause. Each passed the earlier tests -- the first two because "no metadata written" and "== []" are also the outcomes of a path that never ran, the third because the oracle's needle was case-sensitive -- and each is killed now, so every assertion added here is pinned by a measurement rather than by argument. Reverting the class docstring is a documented predicted survival: a docstring has no runtime observer. Whole suite 2933 -> 2948 passed with an identical failed-test name set (7 pre-existing timezone failures, base measured in a clean worktree at origin/main); ruff reports 79 findings at base and at head with identical finding sets.
Internal second-model review (3 rounds, 20 findings, all adjudicated)Before opening this PR I ran my own cold code review plus an independent second-model Fixed in this PR
Disagreed, with evidence
Noted, not blockingTest-docstring volume. The new test class carries more narrative than this repository's Three coverage observations, all on lines this change does not touch, each demonstrated One residual is deliberately out of scope and stated in the PR description rather than |
Pre-PR validation gate (a-i)
Deliberate scope exclusions (decisions, not omissions): Predicted mutation survival, recorded rather than papered over: reverting the Not applicable: no 50/50 randomized-run plan and no Build ID -- this is a Python change in |
|
|
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Symptom
A
memory_recallwhose embedding call fails at the connection layer — DNS, refused,reset, or a transport closed under it by a client reset — renders as
Recalled 0 memories/No relevant memories found.A session concludes memory is empty, redoing work: the"confident wrong answer"
MemoryBackendUnavailableexists to prevent (835d857).Not theoretical —
Connection error.appears 42 times in this instance's 1.95 GBnerve.log(2026-08-03 21:10 UTC), all memorize/chat-side and 0 onmemU recall failed:the class occurs routinely,
recallhas not drawn it yet.Root cause
recall()enumerated transient signatures instead of partitioning failure kinds. Its onlyterm,
_is_transient_llm_error, keys on an HTTP status (429/5xx/auth), so a failure withno HTTP response cannot match it by construction and fell through to
return []beside logicerrors.
retrievehas 0exceptarms, so nothing normalizes it.The fix
[]fromrecall()now means "retrieval returned nothing", for every failure it can observe:_is_llm_transport_failuretests each SDK'sAPIConnectionErrorbase class —openaiandanthropicare disjoint hierarchies,_BedrockLLMClientraises the latter,and each
APITimeoutErrorsubclasses its own, covering timeouts;MemoryBackendUnavailable; everything else propagates tothe caller's pre-existing generic arm, not swallowed;
[];the same arm covers 429s, which are responses.
A genuine miss still returns
[], pinned by a test. All three callers already catch, andengine.py's pre-recall stops freezing a failed recall into session metadata, replayedon every rebuild.
Validation
14 fail / 1 pass at base (the 1 is the genuine-miss control) vs 15 pass fixed; a
17-mutant matrix kills every mutant. Whole suite 2933 -> 2948, identical failed set.
Scope, and why
[]is not globally honestThe widening is
recall()only. The write and category paths keep theirreturn []/False: their tool layers already render an explicit failure (Failed to update memory <id>,NOT saved - MEMORY BACKEND UNAVAILABLE), so they produce no wrong answer — onlya read reporting emptiness lies.
memorize_filealready raises for 429/5xx.[]is honest for everything nerve observes, not globally: memU absorbs a malformed LLMranking response below this arm (
memu/app/retrieve.py:1344logs and returns an emptylist), so on the
method="llm"path a garbled ranker reply still yields a legitimate-lookingempty recall and no exception ever reaches
recall().memu-pyis pinned, so closing thatis a memU-layer change - out of scope, named here so the docstring is not read as the
stronger claim.
Raw
httpxerrors are deliberately untested: both SDKs wrap them(
openai/_base_client.py:1683,anthropic/_base_client.py:2061), and nerve setsclient_backend="sdk"at every profile, soHTTPLLMClientis never constructed — thatfamily has no carrier.
Mutation matrix: 17 mutants, all killed, unmutated control green at both ends, including
M12(residual returns empty - under-classification) andM13/M15(over-classification).The 7 pre-existing whole-suite failures are timezone off-by-ones in
TestResolveEventDatesSyncplus one intest_telegram_sessions, unchanged by this PR;rufffinding set identical modulo line shifts.