Repository navigation
fix(memory): report malformed backend_config values by key name - #5555
WillemJiang merged 3 commits into
Conversation
…Error
failure_policy, workspace_overrides and user_peer_overrides were read with a
falsy-only `or {}` fallback, so a truthy non-mapping (a bare string, a YAML
list) reached .get/.items and escaped as a bare AttributeError from inside
backend construction. Route all three through one _mapping helper that keeps
falsy values meaning "unset" and names the offending key as a ValueError, the
posture the mem0 and OpenViking backends already take.
willem-bd
left a comment
There was a problem hiding this comment.
Verified at head 52a1dc8 in a clean worktree: the five new non-mapping rejection cases fail against main's config.py (AttributeError at the old or {} / .get site) and pass with the fix; the falsy-as-unset semantics are pinned by the 12 passing cases; the new ValueError path in HonchoMemoryManager.read_failures_are_fatal_for_config is safely swallowed by memory_read_failures_are_fatal (logs and fails closed, so no new unhandled error surface), and the strictness now matches mem0's override, while the shipped base-class legacy test only exercises _MinimalBackend and is unaffected. ruff check/format clean; sibling memory suites (mem0, openviking, pluggable, middleware) pass at head. One follow-up-level finding survives, on the scalar knobs just below the fixed hunk — posted inline.
| def from_backend_config(cls, backend_config: dict[str, Any] | None) -> HonchoConfig: | ||
| cfg = dict(backend_config or {}) | ||
| failure_policy = cfg.get("failure_policy") or {} | ||
| failure_policy = _mapping(cfg.get("failure_policy"), "failure_policy") |
There was a problem hiding this comment.
Good guard for the nested keys — flagging that the same crash class stays reachable one block below through the scalar knobs: timeout_seconds: null in YAML reaches float(cfg.get("timeout_seconds", 10.0)) as None and raises TypeError: float() argument must be a string or a real number, not 'NoneType' (same for a mapping/list value, and for connect_timeout_seconds / message_char_limit / max_injection_chars at lines 97-100) — a traceback that, like the AttributeError this PR fixes, names no key and no config file. This file already null-guards the sibling scalars (api_key ... or None at :86, storage_path ... or "" at :103), so the asymmetry looks accidental rather than intentional. It is pre-existing on main and shared with mem0's parse, so a follow-up is fine — but it is the exact operator-facing symptom this change is about, and the falsy-vs-non-mapping distinction _mapping draws here (falsy → default, truthy non-mapping → named ValueError) would extend naturally to a scalar narrow helper.
There was a problem hiding this comment.
Good catch — extended in 632a76d, same branch.
All four knobs now go through one _number(cfg, key, default, cast) helper next to _mapping, drawing the same line: falsy (absent key, YAML null, empty/blank string) means unset and keeps the default, which is what the sibling api_key/storage_path scalars already do here; a value that cannot be cast (list, mapping, non-numeric text) becomes Honcho backend: <key> must be a number, got <type>. float()/int() accept numeric strings today, so "timeout_seconds": "2.5" still parses — a pinned test keeps that honest rather than tightening it by accident. PEP 695 type parameters, since ruff's UP047 rejects a TypeVar generic function here.
Verified on this head:
tests/test_honcho_memory_backend.py→103 passed. Against the previous head the 5 new rejection cases and all 12 unset cases are red there (float([...])/int("wide")andfloat(None)TypeError) and green here; the 2 numeric-string cases pass both ways on purpose.- Sibling memory suites:
test_mem0_memory_backend.py,test_openviking_memory_backend.py,test_memory_manager_pluggable.py,test_memory_middleware.py→118 passed. tests/blocking_io/test_honcho_memory_backend.pyand.../test_openviking_memory_backend.py→ pass;ruff checkandruff format --checkclean on both files.
Left alone deliberately: mem0/config.py:89-92 has the identical unguarded float(cfg.get(...))/int(cfg.get(...)) shape, so top_k: null reproduces the same traceback there. OpenViking is closer to fixed: _optional_float (openviking/config.py:160) already handles the null case, but it takes no key name, so a non-numeric value there still fails as an un-named TypeError/ValueError. I kept this PR to Honcho; say the word and I'll do the same guard for mem0 and give OpenViking's helper a name.
timeout_seconds / connect_timeout_seconds / message_char_limit / max_injection_chars still reached float() / int() with a YAML null or a mapping, so the operator got a TypeError naming neither the key nor the config file. Narrow all four through one _number helper that keeps falsy values meaning "unset" the way the sibling api_key / storage_path scalars already do, and reports a value that cannot be cast as a ValueError on the key. Numeric strings keep parsing, since that is what float/int accept.
willem-bd
left a comment
There was a problem hiding this comment.
Re-review at 632a76d (head moved from 52a1dc8 with one new commit): the round-1 finding is resolved — all four numeric knobs now go through _number, which keeps falsy values meaning "unset" and reports a non-castable value as a ValueError naming the key. Independently verified in a clean worktree: with only config.py reverted to the merge-base (f9f3127), all 17 new numeric tests fail and pass at head; 103 passed in the Honcho file, 248 across the five sibling memory suites, ruff check and format clean; memory_read_failures_are_fatal still fails closed on the new ValueError paths, so no new unhandled error surface. PEP 695 generics are fine (requires-python >=3.12, pattern already used repo-wide). No new defects in the delta; one suggestion-level comment answering the scope question raised in the round-1 thread.
| return value | ||
|
|
||
|
|
||
| def _number[T](cfg: dict[str, Any], key: str, default: T, cast: Callable[[Any], T]) -> T: |
There was a problem hiding this comment.
Delta verified solid at this head, and here is the word you asked for in the round-1 thread on scope. Confirmed both sibling gaps at 632a76d: mem0/config.py builds top_k / score_threshold / max_injection_chars / timeout_seconds with the identical unguarded int/float(cfg.get(...)) shape, so top_k: null still reproduces the exact unnamed-TypeError crash class this PR fixes for Honcho; and OpenViking's _optional_float handles the null case but a non-numeric value still surfaces as a bare ValueError: could not convert string to float with no key name. Since this PR exists to name that class of mistake, extending it to mem0 here is reasonable — the _number shape and the test pattern transfer mechanically. If you'd rather keep this PR Honcho-only, please file the follow-up in the same change set so the mem0 shape isn't lost. Suggestion-level either way — not blocking this PR.
There was a problem hiding this comment.
Thanks — extended in 6a9f8fe4 on this same branch, so the mem0 shape is not lost. Both gaps you named now go through a _number helper of the same shape as _mapping:
backends/mem0/config.py:top_k,score_threshold,max_injection_chars,timeout_seconds—top_k: nullkeeps meaning "unset" andtop_k: "abc"now raisesValueError: mem0 backend_config.top_k must be a number, got 'abc'instead of a bareTypeError: int() argument must be a string....backends/openviking/config.py: the numeric knobs read through_numbertoo, and_optional_floatkeeps itsNone/blank-is-unset behaviour but now names the key, so a bad value reports... must be a number, got 'fast'rather thancould not convert string to float: 'fast'.retrieval.score_thresholdstays on_optional_floatdeliberately — it is the one knob where the backend's own default is meaningful and the field is optional in the schema.
Nine knobs covered in total. The Honcho files are untouched by that commit, so the 632a76d delta you verified is byte-identical at this head. Verified here: source-reverted RED 38 failed, 102 passed, GREEN 140 passed, then 322 passed across the seven memory files; repo-wide ruff check . clean and ruff format --check . reports 1526 files already formatted.
Happy to split the mem0/OpenViking commit into its own PR if you would rather review Honcho and the two siblings separately — say the word and I will rebase it onto a fresh branch off main.
…and OpenViking mem0 casts top_k, score_threshold, max_injection_chars and timeout_seconds, and OpenViking casts timeout_seconds, max_seen_message_ids, retrieval.top_k and retrieval.max_injection_chars, straight through int()/float(). A knob written without a value in YAML therefore escapes as "TypeError: int() argument must be a string..." from inside backend construction, naming neither which knob nor which file is wrong, and a non-numeric value escapes as the equally anonymous "could not convert string to float". Both now resolve numeric knobs through a helper that keeps a value-less key at its default, the way the same dicts already treat failure_policy and allow_insecure_http, and turns an uncastable value into a ValueError that names the knob. OpenViking's score_threshold keeps None as a meaningful value rather than a default to fall back to.
|
Extended to cover the two sibling gaps you flagged — What landed: a Verified on this branch, same procedure as round 1: The one case green both ways is Two notes on form, since you left the choice open:
The description now has a "Scope extension" section with the per-file numbers and the behavioral table. |
|
One contributor-side item is genuinely blocked on a maintainer click, and I would rather name it than leave it implied. This PR's CI has never run: of the seven workflow runs on head Could someone with write access approve (or re-run) the pending workflow runs for this head? I have
The scope is still exactly what |
|
The pending workflow runs on this head were approved overnight and everything is green now: 6/6 runs and all 15 checks pass ( @willem-bd both of your review comments are addressed in |
A string knob written without a value in a memory backend's backend_config (`assistant_peer:`, `api_key_env:`, `default_peer_id:`) parses from YAML as None. The key is present, so dict.get(key, default) returned None and str() carried it on as the literal "None": Honcho prefixed workspaces with "None" and named its assistant peer "None", mem0 looked up an environment variable named "None", and OpenViking stored sessions under a "None/" directory and accepted "None" as the owner user. Read the Honcho, mem0 and OpenViking string knobs through a _text helper that keeps the default for None, the same line _number already draws for numeric knobs (bytedance#5555). A value-less owner_user_id now reaches the existing "must not be empty" check. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fixes #5554
Fixes #5575
Why
backend_configis operator-supplied YAML, and three of the Honcho backend's nested values are read with a falsy-onlyor {}fallback:or {}only rescues falsy values, so a truthy non-mapping survives and crashes on the next call. The most likely way to write it is also the wrong one —failure_policy: fail_closedas an unquoted YAML string:Two things make this worth a guard rather than a workaround at the call site:
failure_policy, nobackend_config, no Honcho — so the operator cannot locate the bad line inconfig.yaml.ValueError(backends/mem0/config.py:76-77, and OpenViking's_mappinghelper atbackends/openviking/config.py:152-157), and the base resolver type-narrows the same key (manager.py:200-201). The shipped testtests/test_memory_manager_interface.py:238-250parametrizes the base policy query with{"failure_policy": "fail_closed"}and{"failure_policy": []}and expectsFalse, i.e. these shapes are meant to be non-fatal config, never an internal error. Honcho was the odd one out;HonchoMemoryManager.read_failures_are_fatal_for_config()(Honcho backend raises AttributeError instead of a config ValueError when failure_policy or *_overrides is not a mapping #5554) inherits the same crash.This continues the posture the backend was already hardened to:
fix(memory): validate Honcho timeout and character limits(#4783) added the parse-timeValueErrorguards in__post_init__for the scalar knobs; these three nested values were left uncovered.What changed
_mapping(value, name)helper inhoncho/config.py, mirroring OpenViking's existing helper, and routed all three nested reads (failure_policy,workspace_overrides,user_peer_overrides) through it instead of duplicating the check at each caller.or {}did —None,{},"",[],0still produce the sameHonchoConfigas before. Only the truthy-non-mapping case changes, and it used to raiseAttributeError, so no currently-loadable configuration behaves differently.TestHonchoConfig.test_non_mapping_nested_values_rejected_as_config_error(5 cases) andtest_empty_nested_values_still_mean_unset(12 cases) totests/test_honcho_memory_backend.py, in the parametrized style the file already uses fortest_rejects_invalid_timeouts.Scope extension (commit
6a9f8fe4)willem-bdpointed at the two remaining backends in review (comment):mem0/config.pybuildstop_k/score_threshold/max_injection_chars/timeout_seconds, andopenviking/config.pybuildstimeout_seconds/max_seen_message_ids/retrieval.top_k/retrieval.max_injection_chars, through bareint()/float()casts, sotop_k: nullstill escapes as the same unnamedTypeErrorclass this PR fixes for Honcho; OpenViking's_optional_floathandlesNonebut a non-numeric value surfaces ascould not convert string to floatwith no key name. Tracked as #5575.That commit adds a
_number(cfg, key, default, cast)helper to each of the two files, in Honcho's shape, and routes eight of those nine knobs through it; the ninth,retrieval.score_threshold, stays on_optional_float, which gains the same key-naming behaviour and keepsNoneas a meaningful value ("apply no threshold") rather than collapsing it to a default. Message text follows each backend's existing convention (mem0 top_k must be a number, got str/OpenViking timeout_seconds must be a number, got str), which is why the three backends phrase the same error slightly differently.Why this is a commit here rather than the separate PR the review left open as an alternative: this account's
createPullRequestagainst the repo is currently rejected (HTTP 404 from REST,does not have the correct permissionsfrom GraphQL — reproducible with any head branch, includingLesereingrape:main), while pushing to an already-open PR branch still works. #5553 and #5555 were both opened normally on 2026-09-18, so if a standalone PR for this is preferred, say so and a maintainer opening it fromLesereingrape:fix/memory-backend-numeric-config-guards(identical content, based on currentmain) is the way to get it.honcho/config.pyandtests/test_honcho_memory_backend.pyare untouched by6a9f8fe4, so the delta verified at632a76dis byte-identical; the extension only edits the mem0 and OpenViking configs plus their two test files.Surface area
from_backend_configin the three HTTP memory backends (honcho,mem0,openviking) and nothing else. No new dependency, no schema change, no I/O, and no helper is imported outside its own backend. For a config that loads today nothing changes: the only shapes whose outcome differs are (a) a truthy non-mapping in a nested Honcho value, which raisedAttributeError, and (b) a numeric knob written with no value, which raised an unnamedTypeErrorand now keeps its default. Numeric strings, valid ranges and every existing rejection path are unchanged.Bug fix verification
tests/test_honcho_memory_backend.py::TestHonchoConfig::test_non_mapping_nested_values_rejected_as_config_errorfails onmainand passes here:The 12 "empty still means unset" cases pass both before and after, which is the point: they pin that this guard cannot change how an existing config loads.
For the extension commit, the mem0 and OpenViking equivalents are
tests/test_mem0_memory_backend.py::TestMem0Config::test_numeric_knob_written_without_a_value_keeps_its_defaulttests/test_mem0_memory_backend.py::TestMem0Config::test_non_numeric_knob_names_the_keytests/test_openviking_memory_backend.py::test_numeric_knob_written_without_a_value_keeps_its_defaulttests/test_openviking_memory_backend.py::test_non_numeric_knob_names_the_keywith only those tests present and the two source files reverted to
main(2ff006b0):and
140 passedon this branch. The single case that is green both ways isopenviking retrieval.score_threshold = None, which_optional_floatalready handled — the one thing that was not broken about it.Validation
The branch also merges into current
main(2ff006b0) with no conflicts (git merge-treeon the merge base reports zero conflict markers), which matters because the base moved after52a1dc8.Behavioral check that the three backends now agree, run on this branch:
Two caveats so nobody is surprised by a red check:
make test(the whole backend suite) is not green on my Windows box for reasons unrelated to this diff — 207 pre-existing/environmental failures, deep-documented in a comment on fix(models): reject non-object JSON in the shared credential file loader #5553 (Windows path-lengthWinError 3/206, POSIX-shell exec, and the no-Node Readability fallback). One of them istests/test_detect_blocking_io_static.py::test_json_output_uses_concise_review_record_schema, which fails identically on an unpatched tree; I re-ran that file here and the other 50 tests pass.AI assistance
_mappinghelper, and every line below were reviewed and verified by the agent at the exact file/line level; no human has read this diff yet, because it was opened during an unattended run; all test output in this description was produced by running the commands on this branch.read_failures_are_fatal_for_configand_parse_override_map's existing empty-value check. (left unticked on purpose: opened during an unattended contribution run, so no human has read the diff yet; the agent read every line and can explain it on request)