Repository navigation
fix(memory): make prefetch timeout configurable and keep late results (rebase of #130788) - #131357
liuhao1024 wants to merge 3 commits into
Conversation
Salvage rebase of NousResearch#130788 (itself a recovery of NousResearch#104562) onto current main. The original branch conflicted on agent/agent_init.py and the surrounding memory manager has since evolved (recall dedupe, provider telemetry, spill config); all main-side behavior is preserved here. - memory.external_prefetch_timeout (config.yaml, default 8s) bounds an external provider's synchronous prefetch; resolved through memory_manager_from_config at agent init - a timed-out prefetch's late result is cached bound to its originating session and query, generation-invalidated on turn start, session rotation, rewind and compression, and reused on the next turn instead of the provider being parked for the session - late values are spill-capped before they are stored or returned Fixes NousResearch#104405
JoaoMarcos44
left a comment
There was a problem hiding this comment.
DEFECT-ONLY adversarial review
Provenance / exact-head binding
- PR #131357 —
fix(memory): make prefetch timeout configurable and keep late results - base:
382c91204d1a2f0c54c778321482ae8f9371a273 - merge-base:
367441274c48a03d12ee9f8d3d9ccd9bc1585392 - head:
fd164da23858d98ca32ded0e714d75a93e67c7df - changed ownership inspected: config →
resolve_external_prefetch_timeout()→MemoryManager._external_prefetch_timeout→ external-prefetchThread.join(timeout) - prior-review reconciliation: zero reviews / zero review threads at the publication rebind, so this does not duplicate an existing finding.
- CI/Nix/Docker are
action_required; I did not treat that metadata as either RED or GREEN.
Coverage matrix
- ordinary finite positive timeout: control
- zero / negative / non-numeric timeout: existing fallback control
- YAML/string NaN: RED
- YAML/string +Infinity: RED
- timeout consumer: actual stdlib
Thread.join(timeout) - late-result/session-generation logic: inspected; no second confirmed defect retained
[P2] Non-finite timeout values bypass validation and crash external memory prefetch
REPRODUCTION
The new resolver converts arbitrary config input with float(raw), then rejects only value <= 0. IEEE NaN and +Infinity both survive that predicate.
I executed the downstream consumer under CPython 3.13.5 with the values the changed resolver accepts:
Thread.join(float("nan"))→ValueError: Invalid value NaN (not a number)Thread.join(float("inf"))→OverflowError: timestamp out of range for platform time_t- normal finite positive timeouts remain valid.
The config path is reachable from ordinary config.yaml: Hermes uses YAML safe_load, and both YAML .nan / .inf and strings "nan" / "inf" become values for which float(raw) is non-finite. The PR explicitly says invalid values fall back so a bad config cannot break execution, but these invalid values escape that boundary.
DIAGNOSIS
CLAIMED INVARIANT: invalid, missing, or non-positive memory.external_prefetch_timeout values fall back to 8s, so malformed configuration cannot break the memory path.
ACTUAL PRODUCTION PATH: config.yaml memory.external_prefetch_timeout → memory_manager_from_config() → resolve_external_prefetch_timeout() → non-finite float stored in MemoryManager → first external prefetch_all() → thread.join(self._external_prefetch_timeout) → exception before the provider result/error handling can run.
PRECONDITIONS / REACHABILITY: external memory provider enabled plus a user-authored non-finite value such as .nan, .inf, nan, or inf. No corrupted runtime state or provider failure is required.
NEGATIVE CONTROL: finite positives (for example 8 or 30) reach join() normally; 0, negative, and ordinary non-numeric strings already take the intended 8s fallback.
FACTS vs INFERENCES: acceptance of NaN/+Inf follows directly from the changed predicate; the observed join() exceptions are runtime facts. The only bounded environmental note is that the exact exception text/range for +Inf can vary by platform, but non-finite values are not legal finite timeout durations.
STRONGEST COUNTER-HYPOTHESIS / REBUTTAL: a schema could theoretically reject non-finite numbers before this resolver. This function is itself the newly introduced defensive config boundary and accepts both mappings and string values; repository config readers use YAML safe loading, which admits non-finite YAML floats. The resolver's own contract therefore cannot rely on an unstated upstream finite-number guarantee.
OBSERVABLE CONSEQUENCE / BLAST RADIUS: the first external-memory prefetch can raise from Thread.join() instead of falling back to the configured default, breaking the turn's memory-prefetch path for an otherwise parseable config. Scope is users with an external provider and a non-finite timeout value.
CALLER / CONTROLLING BOUNDARY / CONSUMER: config → resolve_external_prefetch_timeout (controlling validation owner) → MemoryManager → Thread.join.
FIX: require a finite positive value at the resolver boundary, e.g. reject when not math.isfinite(value) or value <= 0, and keep the constructor invariant consistent so direct construction cannot admit the same state.
RED-before → GREEN-after acceptance contract: parameterize .nan, .inf, nan, inf, 0, negative and junk values; all invalid cases must resolve to _EXTERNAL_PREFETCH_TIMEOUT_S. Add a real external-prefetch call proving none can escape into Thread.join(). Keep finite-positive override controls.
LIMITS OF EVIDENCE
The connected native Hermes workstation is offline, so this is not a full repository-suite receipt. The failing consumer was executed directly on CPython 3.13.5 and the producer/validation path is bound to the exact GitHub head above. No CI metadata was used as defect evidence.
| value = float(raw) | ||
| except (TypeError, ValueError): | ||
| return _EXTERNAL_PREFETCH_TIMEOUT_S | ||
| if value <= 0: |
There was a problem hiding this comment.
[P2] Reject non-finite timeout values here. float('nan') and float('inf') both pass value <= 0, then reach the external-prefetch Thread.join(timeout). I reproduced join(nan) raising ValueError and join(inf) raising OverflowError on CPython 3.13.5. YAML config can produce .nan / .inf, and string nan / inf also survive float(raw). Please require math.isfinite(value) as well as value > 0, with regressions that drive the actual prefetch join.
|
Thanks @JoaoMarcos44 for the P2 catch on non-finite timeouts. Opened a tiny follow-up that requires
Happy for you to land that commit here and close #131448 as redundant. |
|
Thanks @RaviTharuma for the follow-up! Agreed the non-finite guard belongs in its own change — keeping this PR scoped to the configurability + late-result semantics. #131448 is the right home for the |
Making the timeout configurable and keeping late results both check out — the late-value cache replays to the next turn with the same query+session, is dropped on a different query, and the three invalidation hooks (
NaN is the sharper one: Same provider, same 0.3s delay, on base Not theoretical — With that one line in config.yaml, external memory recall is silently dead on that host, and every turn logs
|
NaN compares False against everything, so the existing value <= 0 check never catches it: thread.join(nan) returns instantly and every external prefetch is judged timed out (memory recall silently dead), while inf disables the timeout the config docs promise. YAML 1.1 passes .nan/.inf straight through, so one config line triggers this end to end. resolve_external_prefetch_timeout now falls back to the default for non-finite values like any other invalid input; the MemoryManager constructor (direct construction) raises on them alongside <= 0.
|
Thanks — good catch on the NaN path. Fixed in 30d0f63: Verified end to end on the head tree: |
The stuck-prefetch guard dict was a MemoryManager instance attribute, and a manager is rebuilt per agent init, so every fresh manager whose prefetch timed out spawned one more memory-prefetch-* thread against the same provider with no upper bound (NousResearch#134392). Key the registry by provider name at class level so a stuck call suppresses new spawns from any manager until it returns; the late-result cache stays instance-local. Co-authored-by: Halldrix <noreply@users.noreply.github.com>
What does this PR do?
A slow external memory provider no longer gets "parked" for the rest of the session after one prefetch timeout. This is a salvage rebase of #130788 by @RaviTharuma (itself a recovery of #104562 after an accidental empty force-push): the branch had fallen behind
mainand conflicted onagent/agent_init.py, so the net diff is replayed onto currentmainwith all main-side evolution since (recall dedupe, provider telemetry, spill config wiring, the api_servermemory_managerreuse path) preserved.Three behaviors are delivered on top of current main:
memory.external_prefetch_timeout(config.yaml, default 8.0s, matching the historical hard cap) bounds an external provider's synchronous prefetch.agent_initbuilds the manager through the newmemory_manager_from_config()so the configured value takes effect; invalid or non-positive values fall back to the 8s default so a bad config cannot break startup.MemoryManagerinstance. A manager is rebuilt on each agent init, so a per-instance registry let every fresh manager whose prefetch timed out spawn one more stuckmemory-prefetch-*thread against the same provider with no upper bound ([Bug]: Timed-out external memory prefetch leaks one thread per MemoryManager instance #134392); shared, a stuck call suppresses new spawns from any manager until it returns. The late-result cache stays instance-local.Related Issue
Fixes #104405
Fixes #134392
Salvages #130788
Type of Change
Changes Made
agent/memory_manager.py— addresolve_external_prefetch_timeout()+memory_manager_from_config(); add the_LatePrefetchcache (session/query-bound, generation-invalidated onon_turn_start,on_session_rotate,on_compress); spill-cap late values before store and return; timeout warning now names the config key and states the provider stays active.agent/agent_init.py— construct the manager viamemory_manager_from_config(mem_config)inside the existingis_core_memory_providergate (keeps main'smemory_managerreuse parameter untouched).hermes_cli/config_defaults.py—memory.external_prefetch_timeout: 8.0default with docs comment.cli-config.yaml.example— document the new key in thememory:block.agent/memory_manager.py— hoist_external_prefetch_threads/_external_prefetch_lockto class attributes (_EXTERNAL_PREFETCH_THREADS/_EXTERNAL_PREFETCH_LOCK) so the stuck-call guard spans managers ([Bug]: Timed-out external memory prefetch leaks one thread per MemoryManager instance #134392); the late-result cache and its generation stay per instance.tests/agent/test_memory_provider.py— regression tests: timeout resolution (valid/invalid/missing), late-result reuse for the same session+query, invalidation across session rotation/compression, and a cross-manager invariant test (5 fresh managers against one stuck provider → exactly 1 livememory-prefetch-*thread; reproduces as 5 on unpatched main).How to Test
pytest tests/agent/test_memory_provider.py -q— Observed result: 68 passed (was 55 on main; the 13 new tests cover config resolution, late-result reuse and invalidation, and the cross-manager stuck-thread invariant).pytest tests/agent/ -q -k "memory or agent_init or config_default"— Observed result: 243 passed, 6 failed, 1 skipped; the same 6 fail identically on a cleanupstream/maincheckout (batch-order interaction intest_memory_write_bridge.py/test_inline_tool_executors_memory_args.py, unrelated to this change).memory_manager_from_config({"external_prefetch_timeout": 2.5})._external_prefetch_timeout == 2.5; non-float andNoneconfigs fall back to 8.0.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — the key is documented incli-config.yaml.exampleandconfig_defaults.pycli-config.yaml.exampleif I added/changed config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A