"""MemoryManager — fans the agent's memory hooks out to registered providers. The builtin provider is always allowed; only ONE external plugin provider may be registered at a time (tool-schema bloat, conflicting backends). """ from __future__ import annotations import contextvars import inspect import json import logging import re import threading import time from concurrent.futures import Future, ThreadPoolExecutor, wait from functools import partial from typing import Any, Callable, Dict, List, Optional from agent.redact import redact_for_egress from agent.memory_caller_scope import bound_scope, caller_scope, claim_instance, in_owner_home, refused_tool_message # Moved to agent.memory_context / agent.memory_manager_tools; still importable here because agent.memory_manager is a public # module for plugins (hermes_cli/data/plugin_api.json). from agent.memory_context import StreamingContextScrubber, build_memory_context_block, sanitize_context from agent.memory_manager_tools import (inject_memory_provider_tools, memory_provider_tools_enabled, memory_provider_tools_exposed, normalize_tool_schema) from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION, ctx_bound, spawn_context_thread from agent.skill_commands import extract_user_instruction_from_skill_message from hermes_constants import hermes_home_key from tools.hook_output_spill import get_spill_config, spill_if_oversized from tools.registry import tool_error logger = logging.getLogger(__name__) # Providers that predate the checkpoint-API attribute are on the best-effort v1 contract. _LEGACY_PRE_COMPRESS_API_VERSION = 1 # shutdown_all() drain bound; workers are daemon threads so a wedged provider never # blocks interpreter exit. _SYNC_DRAIN_TIMEOUT_S = 5.0 _EXTERNAL_PREFETCH_TIMEOUT_S = 8.0 # A background provider call (sync_turn / queue_prefetch) running longer than this is treated as # stuck. Well above the shutdown drain bound and the prefetch timeout so a slow-but-healthy backend # is never counted. _BACKGROUND_CALL_STALL_S = 30.0 # Admission for external provider calls per (home, provider); see _StuckCallGate. Python cannot stop a # thread, so every stuck call pins one until it returns. New calls stop once this many calls that started # after the provider's last successful return are overdue; older orphans belong to an outage it has # since recovered from. _MAX_STUCK_CALLS = 4 # While the gate is closed, one call per interval goes through, so a recovered backend proves itself # without waiting for an orphan to return. _PROBE_INTERVAL_S = 60.0 # Hard cap on calls in flight per (home, provider), orphans included: failed probes pin one more thread # each, so admission (probes too) stops here and a backend that stays wedged pins a bounded number. _MAX_INFLIGHT_CALLS = 16 # -- Signature introspection (providers are duck-typed; call shapes vary) ----- def _signature_params(fn: Callable[..., Any]): """``fn``'s parameter mapping, or None when uninspectable (C callables, exotic proxies).""" try: return inspect.signature(fn).parameters except (TypeError, ValueError): return None def _has_var_kwargs(params) -> bool: return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()) def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool: """True if ``fn`` can receive the ``require_checkpoint`` keyword (unreadable signatures -> False). Bare-shape v2 providers (``on_pre_compress(self, messages)``) would raise TypeError on the keyword, which the host would re-raise as a checkpoint failure despite a successful write. """ params = _signature_params(fn) if params is None: return False kind = getattr(params.get("require_checkpoint"), "kind", None) return _has_var_kwargs(params) or kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) # -- Provider egress scrubbing ----------------------------------------------- def _scrub(text: str) -> str: """The shared egress scrub. Like chat-platform and cron delivery it holds even when ``security.redact_secrets`` is off (that setting governs local logs, not what leaves the agent) and fails closed.""" return redact_for_egress(text) _TOKEN_RE = re.compile(r"[A-Za-z0-9_\-./+]{16,}") def _redact_for_provider(*values: Any) -> Any: """Copies of ``values`` with every string scrubbed before it reaches a memory provider. Providers archive whatever they are handed (#115104), so each manager fan-out (turn sync, recall queries, session-end and pre-compress transcripts, memory-tool mirrors, delegation results, provider tool args) goes through ``_scrub``. Walks dicts/lists/tuples so tool-call arguments and multimodal text parts are covered; ``data:`` URLs (inline media) pass through. A value the scrub masked anywhere in the hand-off is then masked everywhere in it, so a key detected in a tool result is also caught where the model repeats it bare in prose. Never mutates the caller's objects: unchanged values come back as-is, changed containers as shallow copies. One value in → one value out; several → a tuple. """ learned: set = set() def walk(value: Any, fix) -> Any: if isinstance(value, str): return value if not value or value.startswith("data:") else fix(value) if isinstance(value, dict): out = {k: walk(v, fix) for k, v in value.items()} return value if all(out[k] is value[k] for k in value) else out if isinstance(value, (list, tuple)): items = [walk(v, fix) for v in value] if all(new is old for new, old in zip(items, value)): return value return tuple(items) if isinstance(value, tuple) else items return value def scrub(text: str) -> str: out = _scrub(text) if out != text: learned.update(set(_TOKEN_RE.findall(text)) - set(_TOKEN_RE.findall(out))) return out scrubbed = [walk(v, scrub) for v in values] if learned: pattern = re.compile("|".join(re.escape(t) for t in sorted(learned, key=len, reverse=True))) scrubbed = [walk(v, lambda t: pattern.sub("[redacted]", t) if pattern.search(t) else t) for v in scrubbed] return scrubbed[0] if len(scrubbed) == 1 else tuple(scrubbed) def _redact_messages_for_egress(messages: Optional[list[Any]]) -> Optional[list[Any]]: """Scrubbed copies of whole transcript rows for a provider; a ``pre_llm_call`` veto's typed text (display-only metadata) never leaves the host.""" from agent.message_metadata import without_withheld_prompts return None if messages is None else list(_redact_for_provider(without_withheld_prompts(messages))) class _StuckCallGate: """Process-wide admission for one kind of external provider call, keyed by (Hermes home, provider). A new MemoryManager is built per agent, so a per-instance view would let every fresh manager start one more call against a hung backend and pin one more thread. Shared, the gate closes once _MAX_STUCK_CALLS calls that started after the key's last successful return are past their deadline, admits one half-open probe per _PROBE_INTERVAL_S while closed, and never lets more than _MAX_INFLIGHT_CALLS be in flight. Calls not yet overdue are not counted, so healthy concurrent sessions run side by side. Each call removes only its own entry. """ def __init__(self) -> None: self.lock = threading.Lock() self.calls: dict[tuple[str, str], dict[object, tuple[float, float]]] = {} # token -> (start, deadline) self.last_ok: dict[tuple[str, str], float] = {} self.probe_at: dict[tuple[str, str], float] = {} # interval start: gate close, then each probe self.skips: dict[tuple[str, str], int] = {} def enter(self, key: tuple[str, str], token: object, timeout: float) -> Optional[tuple[int, int, int]]: """Register ``token`` and return None, or ``(stuck, in_flight, skips)`` when the call is skipped.""" now = time.monotonic() with self.lock: calls = self.calls.setdefault(key, {}) last_ok = self.last_ok.get(key, float("-inf")) stuck = sum(start > last_ok and now > deadline for start, deadline in calls.values()) if len(calls) >= _MAX_INFLIGHT_CALLS: admit = False elif stuck < _MAX_STUCK_CALLS: admit = True self.probe_at.pop(key, None) self.skips.pop(key, None) # gate open: the next outage warns on its first skip else: admit = now - self.probe_at.setdefault(key, now) >= _PROBE_INTERVAL_S if admit: self.probe_at[key] = now if not admit: skips = self.skips[key] = self.skips.get(key, 0) + 1 return stuck, len(calls), skips calls[token] = (now, now + timeout) return None def leave(self, key: tuple[str, str], token: object, *, ok: bool) -> None: with self.lock: calls = self.calls.get(key) if calls is None or calls.pop(token, None) is None: return if calls: if ok: self.last_ok[key] = time.monotonic() return for state in (self.calls, self.last_ok, self.probe_at, self.skips): state.pop(key, None) class MemoryManager: """Builtin provider (always first) plus at most one external provider. Failures in one provider never block the other: every fan-out hook logs and swallows per-provider exceptions. """ # External prefetch threads (#134392) and background sync_turn / queue_prefetch calls. The home is # part of the key because one gateway process can serve several profiles, each with its own config # and credentials for a same-named provider: one profile's hung backend must not silence another's. _PREFETCH_GATE = _StuckCallGate() _BACKGROUND_GATE = _StuckCallGate() def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None: self._providers: list[MemoryProvider] = [] self._tool_to_provider: dict[str, MemoryProvider] = {} self._external_prefetch_spill_config: Optional[dict[str, Any]] = None self._has_external: bool = False timeout = external_prefetch_timeout timeout = _EXTERNAL_PREFETCH_TIMEOUT_S if timeout is None else float(timeout) if timeout <= 0: raise ValueError("external_prefetch_timeout must be positive") self._external_prefetch_timeout = timeout # Captured at build time, inside the profile scope the agent is built for. self._home_key = hermes_home_key() # Single-worker background executor for end-of-turn sync/prefetch, created lazily so # the builtin-only path spawns no threads; one worker serializes a provider's writes. self._sync_executor: Optional[ThreadPoolExecutor] = None self._sync_executor_lock = threading.Lock() # Futures by durability class ("write" / "prefetch") so shutdown can drain FIFO # within a bound, then report exactly what it abandoned. self._background_futures: dict[Future, str] = {} self._shutting_down = False self._teardown_claim: Optional[str] = None # see claim_teardown() self._shutdown_drain_state: dict[str, Any] = { "status": "not_started", "abandoned_writes": 0, "abandoned_prefetches": 0, "active_tasks": 0, } # The profile ``initialize_all`` bound the providers to; tool calls must come from it (#140058). self._bound_scope: Optional[dict[str, Any]] = None # Captured at initialize_all(). A fork ("subagent") holds a provider only for the checkpoint gate; its # turn writes and recall must not reach the user's memory. "primary" until initialized, so the legacy # construction path keeps writing. self._agent_context = "primary" self._tool_call_keywords_cache: dict[int, Optional[frozenset[str]]] = {} def _each_provider(self, label: str, call: Callable[[MemoryProvider], Any], *, level: int = logging.DEBUG, providers: Optional[list[MemoryProvider]] = None, exc_info: bool = False) -> list[Any]: """Call ``call(provider)`` per provider, logging+swallowing failures; returns successes in order. ``label`` completes the log line ``Memory provider ''