Skip to content

fix(gateway): bound the agent cache by memory, not just count and age (#80764, salvage of #80795) - #81127

Merged
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/80795-agent-cache-memory-bound
Aug 7, 2026
Merged

fix(gateway): bound the agent cache by memory, not just count and age (#80764, salvage of #80795)#81127
kshitijk4poor merged 3 commits into
NousResearch:mainfrom
kshitijk4poor:salvage/80795-agent-cache-memory-bound

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Summary

The gateway's per-session agent cache gains the missing third bound — memory. Once the process's anonymous RSS crosses a budget derived from the cgroup limit it actually runs under, least-recently-used transcripts are shed through the existing soft-eviction path and the heap is returned to the OS, so a busy multi-platform gateway stops climbing to the cgroup throttle and dying to SIGKILL every 4-7h (#80764).

Salvage of #80795 by @HexLab98 (both commits cherry-picked, authorship preserved). Closes #80795. Fixes #80764. Supersedes #47848 (idle TTL + max_size become configurable under agent.agent_cache).

Context

Before: the cache was bounded by entry count (128) and idle time (1h) — both blind to bytes. Each cached agent pins _session_messages (full transcript incl. tool output, tens of MB on 100+-tool-call sessions); agents active within the TTL are never swept, so RSS grows until memory.high throttling stalls the SIGTERM flush past systemd's stop timeout. After: a 300s watcher tick compares anon-RSS to the budget and sheds LRU sessions (never mid-turn agents, never the MRU protect_recent, never sessions whose transcript hasn't reached disk); evicted sessions rebuild from the persisted session next turn with the stored system prompt reused verbatim (prompt-cache byte-stability preserved).

Changes

  • Contributor commits (verbatim cherry-picks): gateway/agent_cache_pressure.py (config resolution, cgroup budget, RSS reading, eviction planner), GatewayRunner._sweep_agent_cache_under_pressure(), configurable max_size/idle_ttl_secs, 36 tests (persistence guard exercised against a real AIAgent + SessionDB), docs. Conflict with b3e9e91 in config_defaults.py resolved both-sides-keep (lease key retained).
  • Follow-up commit (review findings, ours):
    • Drain the eviction plan before trim_memory — the batch thread held every evicted agent in its local list while gc.collect+malloc_trim ran, so the in-pass trim freed ~nothing and the next tick over-evicted an extra batch of warm prompt caches per cycle.
    • Clear _db_flush_scan_prefix on soft release — a shallow copy of the flushed transcript that pinned the multi-MB content strings on exactly the agents the valve targets (pressure-evictable ⇒ flushed ⇒ populated).
    • Config-read failure falls back to resolve_agent_cache_bounds({}) ("auto") instead of a permanently-cached disabled valve.
    • protect_recent: false (YAML bool, False == 0) keeps the default MRU protection.
    • The "no evictable session" warning distinguishes sessions blocked on un-flushed persistence (session DB init failure — NFS HERMES_HOME) from mid-turn agents, so the one deployment class where the valve correctly refuses to shed is diagnosable.
    • _cgroup_limit_bytes checks the process's own cgroup (reusing gateway.cgroup_cleanup._own_cgroup_path) before the root files, so systemd MemoryHigh=/MemoryMax= unit limits are detected.

Validation

Check Result
tests/gateway/test_agent_cache_pressure.py (36 original + 5 new guards) 41/41 pass
Sibling eviction suites + cgroup_cleanup + config + mem_trim 129/129 pass
Mutation: revert drain-before-trim → guard fails; remove scan-prefix clear → guard fails both confirmed
ruff + py_compile on touched files clean

Concurrency traced during review: snapshot-before-lock window is the same class as the existing sweeps and is closed in practice by the under-lock _last_flushed_db_idx=0 reset + MRU protection; pop-then-release is identical in semantics to the cap-enforcer path (release_clients touches no shared per-session resources); trim_memory no-ops gracefully on macOS.

Provenance: contributor's design (budget derivation, planner, guard, soft-path reuse, all 36 tests) kept intact; review findings applied on top as a separate commit. Nothing dropped.

HexLab98 and others added 3 commits August 7, 2026 20:28
The per-session agent cache is capped at 128 entries with a 1h idle TTL, and
neither bound knows how many bytes it holds. Each cached agent pins
_session_messages -- the full transcript including tool output, tens of MB on
a session with 100+ tool calls -- so a gateway serving many chats keeps every
warm transcript resident: agents that took a turn inside the TTL are never
idle-swept, and the idle sweep additionally defers finalizable sessions until
they expire. RSS climbs until the cgroup throttles and SIGTERM can no longer
flush inside systemd's stop timeout.

Add the missing bound. Each session-expiry watcher tick compares the process's
anonymous RSS against a budget and, when over, sheds LRU agents through the
same soft-eviction path the cap enforcer uses, then runs malloc_trim so the
freed arenas actually return to the OS. Evicted sessions rebuild their
transcript from the persisted session on the next turn.

Three classes of session are never shed: agents mid-turn, the most recently
used ones, and any session whose transcript has not finished reaching disk
(_last_flushed_db_idx vs len(_session_messages) -- the same divergence the FTS
write-corruption guard reacts to when it preserves live history).

memory_high_mb defaults to "auto", deriving the budget from the cgroup limit
the gateway runs under, so a MemoryHigh/MemoryMax on the unit is respected
without a second number to keep in sync. The two existing bounds become
configurable alongside it under agent.agent_cache.

protect_recent is clamped to half the cache: a couple of sessions can exhaust
the budget on their own, and a fixed MRU guard would then protect everything
and leave the gateway climbing with nothing it would shed.

Fixes NousResearch#80764
Record why the cache needs a third bound and what the pressure pass will and
will not shed, so an operator tuning agent.agent_cache knows which knob to
reach for. Adds the config keys to the session-lifecycle appendix and a user
guide section covering the "auto" cgroup-derived budget.
- Drain the eviction plan (pop + del) before trim_memory: the batch
  thread previously held every evicted agent in its local list while
  gc.collect + malloc_trim ran, so the in-pass trim freed almost
  nothing, the next tick re-read a still-high RSS, and the valve
  over-evicted an extra batch of warm prompt caches per cycle.
- Clear _db_flush_scan_prefix in _release_evicted_agent_soft: it is a
  shallow copy of the flushed transcript (stamped on every successful
  flush) sharing every message dict — and pressure-evictable agents
  have flushed by definition, so it pinned the multi-MB content strings
  on exactly the agents the valve targets.
- Config-read failure now falls back to resolve_agent_cache_bounds({})
  instead of bare AgentCacheBounds(): the dataclass default disables
  the pressure pass, but an absent config section means 'auto' — a
  transient read failure must not permanently switch off the OOM valve.
- protect_recent: false (YAML bool; False == 0) keeps the default MRU
  protection instead of silently disabling it.
- 'No evictable session' warning now distinguishes sessions blocked on
  un-flushed persistence (e.g. session DB never initialized — NFS
  HERMES_HOME) from mid-turn agents, so operators can diagnose why the
  valve isn't shedding instead of being pointed at running turns.
- _cgroup_limit_bytes checks the process's own cgroup (via the existing
  gateway.cgroup_cleanup._own_cgroup_path) before the root files, so a
  systemd unit's MemoryHigh=/MemoryMax= is detected — the root
  memory.high/max read 'max' on those deployments.
- Tests: 5 new guards; drain-before-trim and scan-prefix-clear
  mutation-checked (revert each fix -> its guard fails).
@kshitijk4poor
kshitijk4poor merged commit 83bad5c into NousResearch:main Aug 7, 2026
43 checks passed
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Gateway RSS grows unbounded (full transcripts held in agent cache) — shutdown hang + SIGKILL every 4-7h under multi-session load

3 participants