Files
hermes-agent/gateway/run_agent_cache.py
Teknium 66366d3dab refactor(gateway): split GatewayRunner into 13 run_* mixins + TurnRunner module (run.py 31332 -> 6957)
AST-driven, body-identical move of 359 GatewayRunner methods into cohesive
mixin modules (gateway/run_{voice,adapters,topics,turn,shutdown,busy,
config_loaders,startup,watchers,notifications,inbound,goals,agent_cache}.py)
plus TurnRunner -> gateway/run_turn_runner.py. run.py-internal symbols are
imported lazily inside method bodies so patch('gateway.run.X') keeps
intercepting; neutral deps are top-level; logger name stays 'gateway.run'.
_UNSET moved to leaf gateway/run_common.py (def-time default-arg sentinel).
Whole-module inspect.getsource(gateway_run) AST-walker tests repointed to
the module that now holds the walked code.
2026-09-02 16:11:50 -07:00

1163 lines
53 KiB
Python

"""Agent cache, session model overrides, turn leases, run generations and conversation-scope reset methods for GatewayRunner.
Split out of ``gateway/run.py``; bound onto ``GatewayRunner`` via the MRO.
``gateway.run`` internals are imported lazily inside method bodies (import cycle),
so ``patch("gateway.run.X")`` keeps intercepting them at call time.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import inspect
import threading
import time
from contextlib import suppress
from gateway.config import Platform
from gateway.session import SessionSource, build_session_context_prompt
from hermes_cli.config import cfg_get
from typing import Any, Dict, List, Optional
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
from gateway.run import GatewayRunner, TurnRunner # noqa: F401
# Log-record parity with the origin module.
logger = logging.getLogger("gateway.run")
class GatewayAgentCacheMixin:
"""Agent cache, session model overrides, turn leases, run generations and conversation-scope reset methods for GatewayRunner."""
@classmethod
def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]:
return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS}
@classmethod
def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]:
"""Extract Honcho identity keys, memoized by honcho.json mtime."""
try:
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
path = resolve_config_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = None
memo_key = (str(path), mtime_ns)
cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key)
if cached is not None:
return dict(cached)
hcfg = HonchoClientConfig.from_global_config(config_path=path)
aliases = hcfg.user_peer_aliases or {}
values = {
"honcho.peer_name": hcfg.peer_name,
"honcho.ai_peer": hcfg.ai_peer,
"honcho.pin_peer_name": bool(hcfg.pin_peer_name),
"honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "",
"honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [],
}
cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values}
return dict(values)
except Exception:
return cls._empty_honcho_cache_busting_config()
@classmethod
def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:
"""Pull values that must bust the cached agent, as a flat dict keyed by 'section.key'.
Missing keys / non-dict sections yield None, which still enters the signature ('absent' vs
'present-and-null' differ). Includes the live tool registry generation: MCP reloads mutate
the registry without touching config.yaml, and cached agents freeze their tool schemas.
"""
out: Dict[str, Any] = {}
cfg = user_config if isinstance(user_config, dict) else {}
for section, key in cls._CACHE_BUSTING_CONFIG_KEYS:
section_val = cfg.get(section)
if section == "checkpoints" and isinstance(section_val, bool):
# Preserve legacy ``checkpoints: true`` behavior. A live
# toggle must still rebuild the cached agent.
out[f"{section}.{key}"] = section_val if key == "enabled" else None
elif isinstance(section_val, dict):
out[f"{section}.{key}"] = section_val.get(key)
else:
out[f"{section}.{key}"] = None
try:
from tools.registry import registry
out["tools.registry_generation"] = getattr(registry, "_generation", None)
except Exception:
out["tools.registry_generation"] = None
# Honcho identity-mapping keys live in honcho.json, not user_config.
# Only read that file when Honcho is the active memory provider.
provider = cfg_get(cfg, "memory", "provider")
if isinstance(provider, str) and provider.lower() == "honcho":
out.update(cls._extract_honcho_cache_busting_config())
else:
out.update(cls._empty_honcho_cache_busting_config())
return out
@staticmethod
def _agent_config_signature(
model: str,
runtime: dict,
enabled_toolsets: list,
ephemeral_prompt: str,
cache_keys: dict | None = None,
user_id: str | None = None,
user_id_alt: str | None = None,
skip_context_files: bool = False,
) -> str:
"""Compute a stable string key from agent config values.
Signature change → cached AIAgent rebuilt; unchanged → reused (frozen prompt + schemas for
cache hits). Callers pass ``_extract_cache_busting_config(user_config)`` so config.yaml
edits apply on the next message. ``user_id`` / ``user_id_alt`` participate because Honcho
freezes them into ``HonchoSessionManager`` at init; omitting them in shared-thread keys
(``thread_sessions_per_user=False``) would attribute one user's messages to another's peer.
"""
import hashlib, json as _j
# Fingerprint the FULL credential, not a short prefix: OAuth/JWT-style tokens often share a
# common prefix (e.g. "eyJhbGci"), so a prefix would give false cache hits across auth switches.
_api_key = str(runtime.get("api_key", "") or "")
_api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else ""
_cache_keys_sorted = sorted((cache_keys or {}).items())
blob = _j.dumps(
[
model,
_api_key_fingerprint,
runtime.get("base_url", ""),
runtime.get("provider", ""),
runtime.get("requested_provider", ""),
runtime.get("api_mode", ""),
sorted((runtime.get("capabilities") or {}).items()),
sorted(enabled_toolsets) if enabled_toolsets else [],
# reasoning_config excluded — it's set per-message on the
# cached agent and doesn't affect system prompt or tools.
ephemeral_prompt or "",
_cache_keys_sorted,
str(user_id or ""),
str(user_id_alt or ""),
# skip_context_files changes the agent's frozen system prompt (context files in vs out):
# a toggled edit must rebuild the cached agent, not silently reuse it.
bool(skip_context_files),
],
sort_keys=True,
default=str,
)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def _rehydrate_session_model_override(self, session_key: str) -> None:
"""Lazily restore a persisted /model override after a gateway restart.
``_session_model_overrides`` is in-memory only. Non-secret parts (model/provider/base_url)
are written through on /model (cleared on /new) and read back here on first use; api_key
is never persisted and is re-resolved. No-op when an in-memory override or nothing exists.
"""
from gateway.run import _resolve_runtime_agent_kwargs_for_provider
_rehydrate_state = self._peek_session_state(session_key)
if (
_rehydrate_state is not None
and _rehydrate_state.conversation.model_override is not None
):
return
store = getattr(self, "session_store", None)
if store is None:
return
try:
persisted = store.get_model_override(session_key)
except Exception:
logger.debug(
"Failed to read persisted session model override", exc_info=True
)
return
if not persisted:
return
override: Dict[str, Any] = {
"model": persisted.get("model"),
"provider": persisted.get("provider"),
"base_url": persisted.get("base_url"),
}
provider = persisted.get("provider")
if provider:
# Re-resolve credentials for the persisted provider. On failure (e.g. credentials
# removed since the switch) keep the credential-less override —
# _resolve_session_agent_runtime falls back to env resolution and layers model/provider.
try:
runtime = _resolve_runtime_agent_kwargs_for_provider(provider)
override["api_key"] = runtime.get("api_key")
override["api_mode"] = runtime.get("api_mode")
override["credential_pool"] = runtime.get("credential_pool")
override["request_overrides"] = dict(
runtime.get("request_overrides") or {}
)
override["requested_provider"] = runtime.get("requested_provider")
override["capabilities"] = dict(runtime.get("capabilities") or {})
override["max_tokens"] = runtime.get("max_tokens")
if not override.get("base_url"):
override["base_url"] = runtime.get("base_url")
except Exception:
logger.debug(
"Credential re-resolution failed for persisted override "
"(provider=%s); using credential-less override",
provider, exc_info=True,
)
self._session_state(session_key).conversation.model_override = override
logger.info(
"Rehydrated persisted /model override for session=%s: model=%s provider=%s",
session_key, override.get("model"), provider or "",
)
def _apply_session_model_override(
self, session_key: str, model: str, runtime_kwargs: dict
) -> tuple:
"""Apply /model session overrides if present, returning (model, runtime_kwargs).
Overrides take precedence over config.yaml defaults so the switched model is actually used;
``None`` fields are skipped so partial overrides don't clobber valid defaults.
"""
from gateway.run import _credential_pool_for_provider
_apply_state = self._peek_session_state(session_key)
override = _apply_state.conversation.model_override if _apply_state else None
if not override:
return model, runtime_kwargs
model = override.get("model", model)
for key in (
"provider",
"requested_provider",
"api_key",
"base_url",
"api_mode",
"credential_pool",
"capabilities",
"max_tokens",
):
val = override.get(key)
if val is not None:
runtime_kwargs[key] = val
# request_overrides reflects the switched-to provider; apply whenever the override recorded
# it (even as None) so switching to a provider without configured overrides clears a stale
# value left by the default provider's runtime resolution.
if "request_overrides" in override:
override_request_overrides = override.get("request_overrides")
if isinstance(override_request_overrides, dict) and override_request_overrides:
runtime_kwargs["request_overrides"] = dict(override_request_overrides)
else:
runtime_kwargs["request_overrides"] = override_request_overrides
if (
runtime_kwargs.get("api_key")
and runtime_kwargs.get("credential_pool") is None
and override.get("provider")
):
runtime_kwargs["credential_pool"] = _credential_pool_for_provider(
override.get("provider")
)
return model, runtime_kwargs
def _snapshot_session_model_override(self, session_key: str) -> dict:
"""Capture a gateway session override before a one-turn switch."""
_snap_state = self._peek_session_state(session_key)
override = _snap_state.conversation.model_override if _snap_state else None
return {
"had_override": override is not None,
"override": dict(override) if override is not None else None,
}
def _restore_session_model_override(self, session_key: str, snapshot: dict) -> None:
"""Restore the session override captured before a one-turn switch."""
if not session_key:
return
if snapshot.get("had_override"):
self._session_state(session_key).conversation.model_override = dict(
snapshot.get("override") or {}
)
else:
_rst_state = self._peek_session_state(session_key)
if _rst_state is not None:
_rst_state.conversation.model_override = None
self._evict_cached_agent(session_key)
def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool:
"""Return True if *agent_model* matches an active /model session override."""
_ims_state = self._peek_session_state(session_key)
override = _ims_state.conversation.model_override if _ims_state else None
return override is not None and override.get("model") == agent_model
def _release_running_agent_state(
self,
session_key: str,
*,
run_generation: Optional[int] = None,
) -> bool:
"""Pop ALL per-running-agent state entries for ``session_key``; True when cleared.
Call at every site that ends a running turn, whatever the cause. State that PERSISTS
across turns (model overrides, voice mode, pending approvals, update prompt) is NOT
touched. With ``run_generation``, only clear if that generation is still current, so a
stale async unwind bumped by /stop or /new cannot clobber a newer run (returns False).
"""
if not session_key:
return False
if run_generation is not None and not self._is_session_run_current(
session_key, run_generation
):
return False
state = self._peek_session_state(session_key)
if state is not None:
lease = state.turn.lease
if lease is not None:
try:
lease.release()
except Exception:
logger.debug(
"Failed to release active session slot", exc_info=True
)
# One structured reset instead of a drifting pop-list. Turn-lease tokens are deliberately NOT
# cleared here — _release_turn_lease owns them.
state.turn.clear()
# Turn boundary: a running-agent slot was just released; persist the new (lower) in-flight count
# so the dashboard readout stays current. Preserves gateway_state (see _persist_active_agents).
self._persist_active_agents()
return True
def _release_turn_lease(self, session_key: str, run_generation: int) -> bool:
"""Release the turn lease acquired by (``session_key``, ``run_generation``).
Token map is keyed by (routing key, run generation), so a stale unwind pops only ITS token
and the registry's identity check refuses it if a newer turn holds the lease. Idempotent.
"""
if not session_key:
return False
registry = getattr(self, "_turn_leases", None)
state = self._peek_session_state(session_key)
if state is None or registry is None:
return False
turn = state.turn
if turn.lease_token is None or turn.lease_generation != run_generation:
return False
token = turn.lease_token
turn.lease_token = None
turn.lease_generation = None
try:
return registry.release(token)
except Exception:
logger.debug("Failed to release turn lease", exc_info=True)
return False
def _rebind_turn_lease(
self, session_key: str, run_generation: int, new_session_id: str
) -> bool:
"""Follow a mid-turn session_id rotation with the held turn lease.
Compression can rotate ``session_entry.session_id`` mid-turn; the flush targets the NEW id,
so the serialization boundary must follow or an alias key resolving the new id could start
a concurrent turn the lease never sees. Call at every mid-turn reassignment; no-op if no token.
"""
if not session_key or not new_session_id:
return False
registry = getattr(self, "_turn_leases", None)
state = self._peek_session_state(session_key)
if state is None or registry is None:
return False
turn = state.turn
if turn.lease_token is None or turn.lease_generation != run_generation:
return False
try:
return registry.rebind(turn.lease_token, new_session_id)
except Exception:
logger.debug("Failed to rebind turn lease", exc_info=True)
return False
def _clear_conversation_scope(self, session_key: str, *, reason: str) -> None:
"""Clear ALL conversation-scoped per-session state for ``session_key``.
THE single conversation-boundary funnel — call this and nothing else at /new, /resume,
auto-reset (idle/daily/suspended), expiry finalization and compression-exhausted reset.
New conversation-scoped dicts go in _CONVERSATION_SCOPED_STATE so every boundary picks
them up (hand-copied pop-lists drifted). Turn-scoped state (_running_agents/_ts, slot
leases, turn-lease tokens) is owned by _release_running_agent_state and NOT cleared. Idle
agent-cache eviction is NOT a boundary (a resumed turn rebuilds from these). getattr-guarded.
"""
from gateway.run import _CONVERSATION_SCOPED_STATE
if not session_key:
return
# Structural clear: every conversation-scoped field resets in one
# call — no per-attribute pop-list to drift.
state = self._peek_session_state(session_key)
if state is not None:
state.conversation.clear()
# Legacy plain-dict stores still in _CONVERSATION_SCOPED_STATE (not yet folded into
# SessionState), e.g. _pending_model_notes. SessionState-backed names resolve to MutableMapping
# views (not dict), so the isinstance(dict) guard skips them — already handled above.
for attr in _CONVERSATION_SCOPED_STATE:
store = getattr(self, attr, None)
if isinstance(store, dict):
store.pop(session_key, None)
self._clear_session_boundary_security_state(session_key)
logger.debug(
"Cleared conversation scope for %s (%s)", session_key, reason
)
def _clear_session_boundary_security_state(self, session_key: str) -> None:
"""Clear per-session control state that must not survive a boundary switch."""
if not session_key:
return
pending_skills_reload_notes = getattr(
self, "_pending_skills_reload_notes", None
)
if isinstance(pending_skills_reload_notes, dict):
pending_skills_reload_notes.pop(session_key, None)
_sec_state = self._peek_session_state(session_key)
if _sec_state is not None:
_sec_state.persistent.approvals = None
_sec_state.persistent.update_prompt_pending = False
try:
from tools import slash_confirm as _slash_confirm_mod
except Exception:
_slash_confirm_mod = None
if _slash_confirm_mod is not None:
try:
_slash_confirm_mod.clear(session_key)
except Exception as e:
logger.debug(
"Failed to clear slash-confirm state for session boundary %s: %s",
session_key,
e,
)
try:
from tools.approval import clear_session as _clear_approval_session
except Exception:
return
try:
_clear_approval_session(session_key)
except Exception as e:
logger.debug(
"Failed to clear approval state for session boundary %s: %s",
session_key,
e,
)
def _begin_session_run_generation(self, session_key: str) -> int:
"""Claim a fresh, monotonically increasing run generation token for ``session_key``.
If /stop or /new invalidates the token while the old worker is still unwinding, the late
result is recognized and dropped instead of bleeding into the fresh session.
"""
if not session_key:
return 0
persistent = self._session_state(session_key).persistent
# Monotonic by design (#28686): incremented here, NEVER reset.
persistent.run_generation = int(persistent.run_generation) + 1
return persistent.run_generation
def _invalidate_session_run_generation(self, session_key: str, *, reason: str = "") -> int:
"""Invalidate any in-flight run token for ``session_key``."""
generation = self._begin_session_run_generation(session_key)
if reason:
logger.info(
"Invalidated run generation for %s → %d (%s)",
session_key,
generation,
reason,
)
return generation
def _is_session_run_current(self, session_key: str, generation: int) -> bool:
"""Return True when ``generation`` is still current for ``session_key``."""
if not session_key:
return True
state = self._peek_session_state(session_key)
current = state.persistent.run_generation if state is not None else 0
return int(current) == int(generation)
def _bind_adapter_run_generation(
self,
adapter: Any,
session_key: str,
generation: int | None,
) -> None:
"""Bind a gateway run generation to the adapter's active-session event."""
if not adapter or not session_key or generation is None:
return
try:
interrupt_event = getattr(adapter, "_active_sessions", {}).get(session_key)
if interrupt_event is not None:
setattr(interrupt_event, "_hermes_run_generation", int(generation))
except Exception:
pass
async def _interrupt_and_clear_session(
self,
session_key: str,
source: SessionSource,
*,
interrupt_reason: str,
invalidation_reason: str,
release_running_state: bool = True,
) -> None:
"""Interrupt the current run and clear queued session state consistently."""
from gateway.run import _AGENT_PENDING_SENTINEL, _reap_gateway_turn_processes, request_hard_interrupt
if not session_key:
return
_iac_state = self._peek_session_state(session_key)
running_agent = _iac_state.turn.agent if _iac_state else None
_process_task_id = ""
_process_baseline = None
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
request_hard_interrupt(running_agent, interrupt_reason)
_process_task_id = getattr(
running_agent, "_gateway_turn_process_task_id", ""
)
_process_baseline = getattr(
running_agent, "_gateway_turn_process_baseline", None
)
# Bump the generation BEFORE scheduling the reap thread and capture the post-bump value:
# task_id is session-scoped, so a replacement turn spawning before the reap runs bumps it
# again and the closure sees a stale generation and skips — the replacement's own baseline
# covers its cleanup, so nothing stays unreaped.
_generation_at_interrupt = self._invalidate_session_run_generation(
session_key, reason=invalidation_reason
)
if _process_task_id and _process_baseline is not None:
threading.Thread(
target=_reap_gateway_turn_processes,
args=(_process_task_id, _process_baseline),
kwargs={
"source": "gateway_turn_interrupt",
"is_still_current": lambda: self._is_session_run_current(
session_key, _generation_at_interrupt
),
},
name=f"gateway-turn-reaper-{_process_task_id[:12]}",
daemon=True,
).start()
adapter = self._adapter_for_source(source)
interrupt_session_activity = getattr(
type(adapter), "interrupt_session_activity", None
)
if adapter and callable(interrupt_session_activity):
metadata = self._thread_metadata_for_source(source)
try:
params = inspect.signature(interrupt_session_activity).parameters
accepts_metadata = "metadata" in params or any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in params.values()
)
except (TypeError, ValueError):
accepts_metadata = False
if accepts_metadata:
await adapter.interrupt_session_activity(
session_key, source.chat_id, metadata=metadata
)
else:
await adapter.interrupt_session_activity(session_key, source.chat_id)
if adapter and hasattr(adapter, "get_pending_message"):
adapter.get_pending_message(session_key) # consume and discard
if _iac_state is not None:
_iac_state.persistent.pending_command_text = None
if release_running_state:
self._release_running_agent_state(session_key)
# Evict the cached agent: ``_interrupt_requested`` is only cleared by the turn finalizer,
# so on a hung/still-draining run the flag survives and silently kills the session's NEXT
# message (interrupted=True, api_calls=0, empty response). Like /new and /model, the next
# message rebuilds from history; the old agent keeps its flag so a hung drain still dies.
self._evict_cached_agent(session_key)
async def _refresh_agent_cache_message_count(
self, session_key: str, session_id: Optional[str]
) -> None:
"""Re-baseline a cached agent's stored message_count after THIS turn.
The coherence guard compares on-disk ``message_count`` against the BUILD-time snapshot and
rebuilds on mismatch; without re-baselining after our own rows flush, every turn would
rebuild and destroy prompt caching. Only the count is refreshed (``_sig`` untouched), only
if the same agent is still cached, never when the entry records a different ``session_id``
(another conversation's baseline). DB errors leave the snapshot as-is (one spare rebuild).
"""
from gateway.run import _AGENT_PENDING_SENTINEL
if self._session_db is None or not session_id:
return
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if not _cache_lock or _cache is None:
return
try:
_sess_row = await self._session_db.get_session(session_id)
_live = _sess_row.get("message_count", 0) if _sess_row else None
except Exception:
return
if _live is None:
return
with _cache_lock:
cached = _cache.get(session_key)
# Only re-baseline a live 3-tuple entry; skip pending sentinels, legacy 2-tuples (they opt
# out of the guard), and entries evicted/rebuilt mid-turn.
if (
isinstance(cached, tuple)
and len(cached) > 2
and cached[0] is not _AGENT_PENDING_SENTINEL
):
# A snapshot taken for a different session_id (same session_key, different conversation)
# belongs to a different DB row — leave it alone.
_snapshot_sid = cached[3] if len(cached) > 3 else None
if _snapshot_sid is not None and _snapshot_sid != session_id:
return
if cached[2] != _live:
if _snapshot_sid is None:
# Legacy 3-tuple: preserve the 3-element shape for callers indexing ``cached[2]``.
_cache[session_key] = (cached[0], cached[1], _live)
else:
_cache[session_key] = (
cached[0], cached[1], _live, _snapshot_sid,
)
def _set_pending_turn_sidecar_notes(self, session_key: str, notes: List[str]) -> None:
"""Stage per-turn must-deliver notes for the next agent run (one-shot)."""
if not session_key or not notes:
return
self._session_state(session_key).conversation.sidecar_notes = list(notes)
def _consume_pending_turn_sidecar_notes(self, session_key: str) -> List[str]:
if not session_key:
return []
state = self._peek_session_state(session_key)
if state is None:
return []
staged = state.conversation.sidecar_notes
state.conversation.sidecar_notes = []
return list(staged) if isinstance(staged, list) else []
def _voice_channel_sidecar_note(self, event, source: SessionSource, session_key: str) -> Optional[str]:
"""Return a ``[Voice channel now: ...]`` note when VC state changed.
Unchanged state returns ``None`` so per-turn member/speaking churn can't touch the prompt.
"""
if source.platform != Platform.DISCORD:
return None
adapter = self.adapters.get(Platform.DISCORD)
guild_id = self._get_guild_id(event)
if not (guild_id and adapter and hasattr(adapter, "get_voice_channel_context")):
return None
try:
vc_now = adapter.get_voice_channel_context(guild_id) or ""
except Exception:
logger.debug("voice-channel context read failed", exc_info=True)
return None
vc_prev = None
if session_key:
_vc_state = self._session_state(session_key)
vc_prev = _vc_state.conversation.vc_last
_vc_state.conversation.vc_last = vc_now
if vc_now == (vc_prev if vc_prev is not None else ""):
return None
if not vc_now:
return "[Voice channel now: not connected to a voice channel]"
return f"[Voice channel now: {vc_now}]"
def _pinned_session_context_prompt(
self, context, redact_pii: bool, session_key: Optional[str]
) -> str:
"""Return the session-context prompt, pinned per session.
Key hit → pinned bytes reused VERBATIM (immune to renderer nondeterminism); key miss →
re-render ``build_session_context_prompt`` and re-pin (rename, topic edit, /sethome, ...).
"""
_eph_key = self._ephemeral_change_key(context, redact_pii)
_eph_pin = None
if session_key:
_pin_state = self._peek_session_state(session_key)
_eph_pin = _pin_state.conversation.ephemeral_pin if _pin_state else None
if _eph_pin is not None and _eph_pin[0] == _eph_key:
return _eph_pin[1]
text = build_session_context_prompt(context, redact_pii=redact_pii)
if session_key:
self._session_state(session_key).conversation.ephemeral_pin = (
_eph_key,
text,
)
return text
@staticmethod
def _ephemeral_change_key(context, redact_pii: bool) -> str:
"""Hash the exact inputs ``build_session_context_prompt`` renders.
Invariant (tests/gateway/test_prompt_tail_freeze.py): any input whose change alters the
rendered bytes MUST appear here — omission means a stale pinned prompt; extras only re-render.
"""
import hashlib
src = context.source
platform = src.platform.value if src.platform else ""
discord_ids: tuple = ()
discord_tools = ""
if src.platform == Platform.DISCORD:
from gateway.session import _discord_tools_loaded
discord_tools = "1" if _discord_tools_loaded() else "0"
discord_ids = (
str(src.guild_id or ""),
str(src.parent_chat_id or ""),
str(src.thread_id or ""),
str(src.chat_id or ""),
# Only PRESENCE is rendered (the id itself arrives per-turn in the user message) —
# keying on the value would re-render every message for zero byte change.
"1" if src.message_id else "0",
)
# Slack's capability-aware platform note is gated on _slack_tools_loaded() — the gate state must
# be in the key (same parity contract as the Discord gate above) so a config / MCP-registration
# flip re-renders once instead of serving a stale pinned note for the rest of the session.
slack_tools = ""
if src.platform == Platform.SLACK:
from gateway.session import _slack_tools_loaded
slack_tools = "1" if _slack_tools_loaded() else "0"
try:
from hermes_constants import display_hermes_home
home_display = str(display_hermes_home())
except Exception:
home_display = ""
key_tuple = (
platform,
str(src.chat_id or ""),
str(src.thread_id or ""),
str(src.chat_type or ""),
str(src.chat_name or ""),
str(src.chat_topic or ""),
str(src.user_name or ""),
str(src.user_id or ""),
str(getattr(src, "profile", None) or ""),
bool(context.shared_multi_user_session),
discord_ids,
discord_tools,
slack_tools,
tuple(p.value for p in context.connected_platforms),
tuple(
(
p.value,
str(getattr(hc, "name", "") or ""),
str(getattr(hc, "chat_id", "") or ""),
)
for p, hc in context.home_channels.items()
),
bool(redact_pii),
home_display,
)
return hashlib.sha256(repr(key_tuple).encode("utf-8")).hexdigest()
def _evict_cached_agent(self, session_key: str) -> None:
"""Remove a cached agent for a session (called on /new, /model, etc).
Also soft-releases the evicted agent's LLM client pool (``release_clients()``): AIAgent
holds reference cycles that delay collection, so without it gateway RSS grows across /new.
Soft = frees clients and per-turn child subagents but PRESERVES the session's terminal
sandbox, browser daemon and bg processes (keyed on task_id) since the session may resume.
True boundaries (/new) call ``_cleanup_agent_resources`` first (release is idempotent).
Cleanup runs on a daemon thread so ``_agent_cache_lock`` never spans slow socket teardown.
"""
from gateway.run import _AGENT_PENDING_SENTINEL
# Prompt-stability state rides the agent-cache lifecycle: a fresh agent must re-render its
# session-context bytes (the pin) and re-see the current voice-channel state once.
_evict_state = self._peek_session_state(session_key)
if _evict_state is not None:
_evict_state.conversation.ephemeral_pin = None
_evict_state.conversation.vc_last = None
_lock = getattr(self, "_agent_cache_lock", None)
evicted = None
if _lock:
with _lock:
evicted = self._agent_cache.pop(session_key, None)
else:
_cache = getattr(self, "_agent_cache", None)
if _cache is not None:
evicted = _cache.pop(session_key, None)
agent = evicted[0] if isinstance(evicted, tuple) and evicted else evicted
if agent is None or agent is _AGENT_PENDING_SENTINEL:
return
# Don't tear down an agent that's actively mid-turn — its client,
# sandbox and child subagents are in use by the running request.
running_ids = self._running_agent_ids()
if id(agent) in running_ids:
return
try:
threading.Thread(
target=self._release_evicted_agent_soft,
args=(agent,),
daemon=True,
name=f"agent-evict-{str(session_key)[:24]}",
).start()
except Exception:
# If we can't spawn a thread (interpreter shutdown), release
# inline as a best-effort fallback.
with suppress(Exception):
self._release_evicted_agent_soft(agent)
def _commit_memory_before_soft_evict(self, agent: Any, key: str) -> None:
"""Fire on_session_end extraction before soft-evicting a live agent.
Soft eviction keeps the session resumable and does NOT fire ``on_session_end`` — that is
``_session_expiry_watcher``'s job at true expiry. But the watcher tears down whatever it
finds in ``_agent_cache``; if the LRU cap soft-evicts first, memory providers never see the
transcript. So commit extraction here via ``commit_memory_session`` (no teardown). Only for
finalizable sessions — ``mode == "none"`` never finalizes. Best-effort: failures swallowed.
"""
if agent is None or not hasattr(agent, "commit_memory_session"):
return
if getattr(agent, "_memory_manager", None) is None:
return # no external memory provider — nothing to commit
try:
_store = getattr(self, "session_store", None)
if _store is None:
return
_store._ensure_loaded()
entry = _store._entries.get(key)
if entry is None:
return
# Compensate only when the watcher would expect this agent at expiry (finite policy, not yet
# expired). Expired sessions are torn down by the watcher; mode="none" is never finalized.
if not _store.is_session_finalizable(entry):
return
if _store._is_session_expired(entry):
return
messages = getattr(agent, "_session_messages", None)
agent.commit_memory_session(messages if isinstance(messages, list) else None)
logger.debug(
"Committed on_session_end extraction before soft-evicting "
"finalizable session=%s (cache pressure, pre-expiry)", key,
)
except Exception as _e:
logger.debug("Pre-evict memory commit failed for %s: %s", key, _e)
def _commit_then_release_soft(self, agent: Any, key: str) -> None:
"""Commit end-of-session memory (if warranted), then soft-release.
Runs on the daemon eviction thread so neither blocks the caller's held cache lock. Order
matters: commit needs the live memory manager before ``release_clients`` drops the buffer.
"""
self._commit_memory_before_soft_evict(agent, key)
self._release_evicted_agent_soft(agent)
def _release_evicted_agent_soft(self, agent: Any) -> None:
"""Soft cleanup for cache-evicted agents — preserves session tool state.
Unlike _cleanup_agent_resources (full teardown), an evicted session may resume, so its
terminal sandbox, browser daemon and bg processes must outlive the AIAgent instance.
"""
if agent is None:
return
try:
if hasattr(agent, "release_clients"):
agent.release_clients()
else:
# Older agent instance (shouldn't happen in practice) —
# fall back to the legacy full-close path.
self._cleanup_agent_resources(agent)
except Exception:
pass
# Free conversation history — tens of MB of tool output on heavy 100+-tool-call sessions.
# release_clients() preserves session tool state for resume, but the message list is rebuilt from
# persisted session JSON on the next turn, so dropping it here is safe.
if hasattr(agent, "_session_messages"):
agent._session_messages = []
# _db_flush_scan_prefix (run_agent.py, stamped on every successful flush) is a shallow copy
# sharing every message dict of the flushed transcript, so leaving it pins the multi-MB strings
# this eviction frees. Pressure-evictable agents have flushed by definition, so it's populated.
if hasattr(agent, "_db_flush_scan_prefix"):
agent._db_flush_scan_prefix = None
def _agent_cache_bounds(self):
"""Operator-configured agent-cache bounds, resolved once per process.
Resolved lazily rather than in ``__init__`` so it also works for the
``__new__``-constructed runners used by tests and by the slash-command mixin.
"""
from gateway.run import _load_gateway_config
bounds = getattr(self, "_agent_cache_bounds_cache", None)
if bounds is None:
from gateway.agent_cache_pressure import resolve_agent_cache_bounds
try:
bounds = resolve_agent_cache_bounds(_load_gateway_config())
except Exception as _e:
logger.debug("Agent cache bounds config read failed: %s", _e)
# Resolve from an empty config rather than bare AgentCacheBounds(): the dataclass default
# has memory_high_mb=None (pressure pass OFF) but an *absent* section means "auto" — a
# transient config read failure must not permanently disable the OOM valve.
bounds = resolve_agent_cache_bounds({})
self._agent_cache_bounds_cache = bounds
return bounds
def _agent_cache_cap(self) -> int:
"""Effective LRU cap — the configured override, else the default."""
from gateway.run import _AGENT_CACHE_MAX_SIZE
configured = self._agent_cache_bounds().max_size
return configured if configured else _AGENT_CACHE_MAX_SIZE
def _agent_cache_idle_ttl(self) -> float:
"""Effective idle TTL in seconds — configured override, else default."""
from gateway.run import _AGENT_CACHE_IDLE_TTL_SECS
configured = self._agent_cache_bounds().idle_ttl_secs
return configured if configured else _AGENT_CACHE_IDLE_TTL_SECS
def _sweep_agent_cache_under_pressure(self) -> int:
"""Shed cached transcripts once the gateway heap nears its budget; returns count evicted.
The LRU cap counts entries and the idle sweep counts seconds; neither knows one cached agent
pins a full ``_session_messages`` transcript (tens of MB). Warm and finalizable agents are
never swept, so RSS climbs until the cgroup throttles. Above the anonymous-RSS budget this
soft-evicts LRU agents (transcript rebuilt from the persisted session next turn). Never
touched: agents mid-turn, the most recently used sessions, and transcripts not yet on disk.
"""
from gateway.run import _AGENT_PENDING_SENTINEL
from gateway.agent_cache_pressure import (
plan_pressure_evictions,
read_anon_rss_mb,
transcript_persistence_caught_up,
)
bounds = self._agent_cache_bounds()
if not bounds.memory_high_mb:
return 0
_cache = getattr(self, "_agent_cache", None)
_lock = getattr(self, "_agent_cache_lock", None)
if not _cache or _lock is None:
# Nothing cached — whatever is using the heap, it isn't us, and
# warning about it every tick would point at the wrong subsystem.
return 0
rss_mb = read_anon_rss_mb()
if rss_mb is None or rss_mb < bounds.memory_high_mb:
return 0
running_ids = self._running_agent_ids()
def _is_evictable(key: str, agent: Any) -> bool:
if agent is None or agent is _AGENT_PENDING_SENTINEL:
return False
if id(agent) in running_ids:
return False
return transcript_persistence_caught_up(agent)
with _lock:
ordered = [
(key, entry[0] if isinstance(entry, tuple) and entry else entry)
for key, entry in _cache.items()
]
plan = plan_pressure_evictions(
ordered,
is_evictable=_is_evictable,
max_evictions=bounds.max_evictions_per_pass,
protect_recent=bounds.protect_recent,
)
for key, _ in plan:
_cache.pop(key, None)
if not plan:
_mid_turn = sum(1 for _, a in ordered if a is not None and id(a) in running_ids)
_unflushed = sum(
1
for _, a in ordered
if a is not None
and a is not _AGENT_PENDING_SENTINEL
and id(a) not in running_ids
and not transcript_persistence_caught_up(a)
)
logger.warning(
"Agent cache pressure: anon RSS %dMB over budget %dMB but no "
"evictable session (%d cached, %d mid-turn, %d blocked on "
"un-flushed persistence)%s",
rss_mb, bounds.memory_high_mb, len(ordered), _mid_turn, _unflushed,
(
" — transcripts are not reaching the session DB "
"(session persistence disabled or failing?); the memory "
"valve cannot shed sessions until they persist."
if _unflushed and not _mid_turn
else " — memory will keep climbing until those turns finish."
),
)
return 0
evicted_count = len(plan)
logger.warning(
"Agent cache pressure: anon RSS %dMB over budget %dMB — evicting "
"%d LRU session(s): %s",
rss_mb, bounds.memory_high_mb, evicted_count,
", ".join(key for key, _ in plan),
)
try:
threading.Thread(
target=self._release_pressure_batch,
args=(plan,),
daemon=True,
name="agent-cache-pressure",
).start()
except Exception:
self._release_pressure_batch(plan)
# NOTE: _release_pressure_batch drains `plan` in place (so the trim runs with no lingering
# agent refs) — len(plan) is 0 once the daemon thread finishes, hence the pre-captured count.
return evicted_count
def _release_pressure_batch(self, plan: List[tuple]) -> None:
"""Release a pressure-evicted batch, then return the heap to the OS.
Sequential on one daemon thread (the batch is capped; the goal is reclaiming memory, not
racing teardowns). The trailing ``malloc_trim`` makes RSS actually fall — glibc otherwise
keeps freed arenas. The plan is drained (``pop`` + ``del``), not iterated, so no local
reference pins evicted agents during ``gc.collect`` + trim (else the valve over-evicts).
"""
while plan:
key, agent = plan.pop(0) # FIFO — evict LRU-first order preserved
try:
self._commit_then_release_soft(agent, key)
except Exception as _e:
logger.debug("Pressure release failed for %s: %s", key, _e)
del agent
try:
from hermes_cli.mem_trim import trim_memory
trim_memory(force=True, reason="agent_cache_pressure")
except Exception:
pass
def _enforce_agent_cache_cap(self) -> None:
"""Evict oldest cached agents when cache exceeds the LRU cap. Requires _agent_cache_lock.
Resource cleanup runs on a daemon thread so the lock is not held over slow teardown.
Agents in _running_agents are SKIPPED (their clients/sandboxes/subagents are in use); if
every LRU candidate is active the cache stays over cap until the next insert.
"""
_cache = getattr(self, "_agent_cache", None)
if _cache is None:
return
# OrderedDict.popitem(last=False) pops oldest; plain dict lacks the
# arg so skip enforcement if a test fixture swapped the cache type.
if not hasattr(_cache, "move_to_end"):
return
# Snapshot of agent instances mid-turn, keyed by id() so lookup is O(1) and independent of
# AIAgent.__eq__ (which MagicMock overrides in tests).
running_ids = self._running_agent_ids()
# Walk LRU → MRU; only the first (size - cap) LRU positions are candidates. An active slot is
# SKIPPED rather than evicting a newer entry — that would penalise a fresh session (no cache
# history) to protect a long-running one. Cache may stay over cap until the next insert.
cap = self._agent_cache_cap()
excess = max(0, len(_cache) - cap)
evict_plan: List[tuple] = [] # [(key, agent), ...]
if excess > 0:
ordered_keys = list(_cache.keys())
for key in ordered_keys[:excess]:
entry = _cache.get(key)
agent = entry[0] if isinstance(entry, tuple) and entry else None
if agent is not None and id(agent) in running_ids:
continue # active mid-turn; don't evict, don't substitute
evict_plan.append((key, agent))
for key, _ in evict_plan:
_cache.pop(key, None)
remaining_over_cap = len(_cache) - cap
if remaining_over_cap > 0:
logger.warning(
"Agent cache over cap (%d > %d); %d excess slot(s) held by "
"mid-turn agents — will re-check on next insert.",
len(_cache), cap, remaining_over_cap,
)
for key, agent in evict_plan:
logger.info(
"Agent cache at cap; evicting LRU session=%s (cache_size=%d)",
key, len(_cache),
)
if agent is not None:
# Commit end-of-session memory, then soft-release, both on the daemon thread so the
# (possibly network-bound) provider call never blocks the held cache lock.
threading.Thread(
target=self._commit_then_release_soft,
args=(agent, key),
daemon=True,
name=f"agent-cache-evict-{key[:24]}",
).start()
def _sweep_idle_cached_agents(self) -> int:
"""Evict cached agents idle past the idle TTL; returns the number evicted.
Acquires the cache lock internally (safe from the expiry watcher); cleanup on daemon
threads. Agents in _running_agents are SKIPPED — tearing down an active turn crashes it.
"""
_cache = getattr(self, "_agent_cache", None)
_lock = getattr(self, "_agent_cache_lock", None)
if _cache is None or _lock is None:
return 0
now = time.time()
idle_ttl = self._agent_cache_idle_ttl()
to_evict: List[tuple] = []
running_ids = self._running_agent_ids()
with _lock:
for key, entry in list(_cache.items()):
agent = entry[0] if isinstance(entry, tuple) and entry else None
if agent is None:
continue
if id(agent) in running_ids:
continue # mid-turn — don't tear it down
last_activity = getattr(agent, "_last_activity_ts", None)
if last_activity is None:
continue
if (now - last_activity) > idle_ttl:
# If the session hasn't actually expired in the store (e.g. daily-reset fires hours
# after the last message), keep the agent cached so the expiry watcher can still find
# it and call on_session_end() with the live transcript. BUT only defer when the
# watcher will EVER finalize it: for mode == "none" (is_session_finalizable() False)
# deferring pins the agent for the gateway's lifetime — the leak this sweep relieves.
# Those fall through to soft eviction WITHOUT on_session_end, correctly (never a
# session-end boundary). Finite sessions evicted under LRU-cap pressure are covered
# by _commit_memory_before_soft_evict on the cap path.
session_entry = None
_store = getattr(self, "session_store", None)
try:
if _store is not None:
_store._ensure_loaded()
session_entry = _store._entries.get(key)
except Exception:
session_entry = None
if (
session_entry is not None
and _store is not None
and _store.is_session_finalizable(session_entry)
and not _store._is_session_expired(session_entry)
):
continue # keep agent — finite session hasn't expired
to_evict.append((key, agent))
for key, _ in to_evict:
_cache.pop(key, None)
for key, agent in to_evict:
logger.info(
"Agent cache idle-TTL evict: session=%s (idle=%.0fs)",
key, now - getattr(agent, "_last_activity_ts", now),
)
threading.Thread(
target=self._release_evicted_agent_soft,
args=(agent,),
daemon=True,
name=f"agent-cache-idle-{key[:24]}",
).start()
return len(to_evict)