# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""Memory-pressure bounds for the gateway's per-session AIAgent cache.
|
|
|
|
Each cached ``AIAgent`` pins its full live transcript (tens of MB on a tool-heavy
|
|
session); the LRU cap counts entries, not bytes, and the idle TTL defers eviction
|
|
for busy sessions, so neither sees actual memory use. This module supplies that
|
|
signal — own anonymous RSS against a budget derived from the cgroup limit — and
|
|
``GatewayRunner`` sheds LRU transcripts via soft eviction (rebuilt from the
|
|
persisted session next turn). Pure/read-only; config under ``agent.agent_cache``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, List, Optional, Tuple
|
|
|
|
# Shed well under the limit: once cgroup ``memory.high`` throttling kicks in (swap full),
|
|
# a SIGTERM flush cannot finish inside systemd's stop timeout.
|
|
_AUTO_BUDGET_FRACTION = 0.65
|
|
# Below this a budget is noise — small containers would evict every pass and never keep a warm prefix.
|
|
_AUTO_BUDGET_FLOOR_MB = 512
|
|
_DEFAULT_MAX_EVICTIONS_PER_PASS = 16
|
|
# Never shed the hottest sessions: their prompt cache is worth the most; evicting them
|
|
# just moves the cost to the next turn.
|
|
_DEFAULT_PROTECT_RECENT = 8
|
|
_BYTES_PER_MB = 1024 * 1024
|
|
_OFF_WORDS = frozenset({"", "off", "none", "false", "disabled"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentCacheBounds:
|
|
"""Operator-facing bounds. ``max_size``/``idle_ttl_secs`` are ``None`` when unset
|
|
so ``gateway/run.py`` keeps its defaults; ``memory_high_mb`` ``None`` = pressure eviction off."""
|
|
|
|
max_size: Optional[int] = None
|
|
idle_ttl_secs: Optional[float] = None
|
|
memory_high_mb: Optional[int] = None
|
|
max_evictions_per_pass: int = _DEFAULT_MAX_EVICTIONS_PER_PASS
|
|
protect_recent: int = _DEFAULT_PROTECT_RECENT
|
|
|
|
|
|
def _is_int(value: Any) -> bool:
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
|
|
|
|
def _positive(value: Any, cast: Callable[[Any], Any] = int) -> Any:
|
|
"""``cast(value)`` if it is a positive number (bools rejected), else None."""
|
|
try:
|
|
parsed = None if isinstance(value, bool) or value is None else cast(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return parsed if parsed is not None and parsed > 0 else None
|
|
|
|
|
|
def _cgroup_limit_bytes() -> Optional[int]:
|
|
"""Memory limit this process runs under, if cgroup-capped.
|
|
|
|
Prefers v2 ``memory.high`` (the throttling point) over ``memory.max``, then v1.
|
|
Own cgroup first (where a systemd unit's ``MemoryHigh=``/``MemoryMax=`` lands —
|
|
root reads ``max`` there), then root for container-style limits. ``max`` and
|
|
the v1 near-2^63 sentinel mean unlimited.
|
|
"""
|
|
if sys.platform != "linux":
|
|
return None
|
|
try:
|
|
from gateway.cgroup_cleanup import _own_cgroup_path
|
|
|
|
own = _own_cgroup_path()
|
|
except Exception:
|
|
own = None
|
|
roots = ([f"/sys/fs/cgroup{own}"] if own and own != "/" else []) + ["/sys/fs/cgroup"]
|
|
for candidate in [f"{r}/memory.{f}" for r in roots for f in ("high", "max")] + ["/sys/fs/cgroup/memory/memory.limit_in_bytes"]:
|
|
try:
|
|
limit = int(Path(candidate).read_text(encoding="utf-8-sig").strip())
|
|
except (OSError, ValueError): # unreadable, empty, or "max"
|
|
continue
|
|
if 0 < limit < (1 << 62):
|
|
return limit
|
|
return None
|
|
|
|
|
|
def _total_memory_bytes() -> Optional[int]:
|
|
try:
|
|
return int(os.sysconf("SC_PAGE_SIZE")) * int(os.sysconf("SC_PHYS_PAGES"))
|
|
except (OSError, ValueError, AttributeError):
|
|
pass
|
|
try:
|
|
import psutil # type: ignore
|
|
|
|
return int(psutil.virtual_memory().total)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def resolve_memory_high_mb(setting: Any) -> Optional[int]:
|
|
"""Absolute MB budget: ``"auto"`` derives from the cgroup limit (or total RAM when
|
|
uncapped); a positive number is literal; anything falsy/off disables the pass."""
|
|
if isinstance(setting, str):
|
|
normalized = setting.strip().lower()
|
|
if normalized != "auto":
|
|
return None if normalized in _OFF_WORDS else _positive(normalized)
|
|
elif setting is False:
|
|
return None
|
|
elif setting is not True:
|
|
return _positive(setting)
|
|
limit = _cgroup_limit_bytes() or _total_memory_bytes()
|
|
if not limit:
|
|
return None
|
|
budget = int(limit * _AUTO_BUDGET_FRACTION / _BYTES_PER_MB)
|
|
return budget if budget >= _AUTO_BUDGET_FLOOR_MB else None
|
|
|
|
|
|
def resolve_agent_cache_bounds(config: Any) -> AgentCacheBounds:
|
|
"""Read ``agent.agent_cache`` from the *raw* config: the gateway loader does not
|
|
deep-merge ``DEFAULT_CONFIG``, so callers can tell "operator chose 128" from "unset"."""
|
|
section = (config.get("agent") or {}).get("agent_cache") if isinstance(config, dict) else None
|
|
if not isinstance(section, dict):
|
|
section = {}
|
|
protect_recent = section.get("protect_recent")
|
|
protect_parsed = _positive(protect_recent)
|
|
# 0 means "shed anything" — distinct from unset. The bool guard keeps `protect_recent: false`
|
|
# (False == 0) on the default instead of silently disabling MRU protection.
|
|
if protect_parsed is None and _is_int(protect_recent) and protect_recent == 0:
|
|
protect_parsed = 0
|
|
return AgentCacheBounds(
|
|
max_size=_positive(section.get("max_size")),
|
|
idle_ttl_secs=_positive(section.get("idle_ttl_secs"), float),
|
|
memory_high_mb=resolve_memory_high_mb(section.get("memory_high_mb", "auto")),
|
|
max_evictions_per_pass=_positive(section.get("max_evictions_per_pass")) or _DEFAULT_MAX_EVICTIONS_PER_PASS,
|
|
protect_recent=_DEFAULT_PROTECT_RECENT if protect_parsed is None else protect_parsed,
|
|
)
|
|
|
|
|
|
def read_anon_rss_mb() -> Optional[int]:
|
|
"""Anonymous RSS in MB (where cached transcripts live; file-backed pages are noise),
|
|
or None. ``/proc/self/status`` first; psutil covers other platforms (total RSS only)."""
|
|
try:
|
|
from hermes_cli.mem_trim import collect_memory_snapshot
|
|
|
|
snapshot = collect_memory_snapshot()
|
|
for key in ("rss_anon_kib", "rss_kib"):
|
|
kib = snapshot.get(key)
|
|
if isinstance(kib, int) and kib > 0:
|
|
return kib // 1024
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import psutil # type: ignore
|
|
|
|
return int(psutil.Process(os.getpid()).memory_info().rss / _BYTES_PER_MB)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def transcript_persistence_caught_up(agent: Any) -> bool:
|
|
"""True when the live transcript is fully on disk.
|
|
|
|
Soft eviction rebuilds from the persisted session, so it is only safe once
|
|
``_last_flushed_db_idx`` (advanced only on a fully successful write) has caught
|
|
up. Unknown shapes are *not* caught up: a skipped eviction costs memory, a
|
|
wrong one costs the conversation.
|
|
"""
|
|
messages, flushed = getattr(agent, "_session_messages", None), getattr(agent, "_last_flushed_db_idx", None)
|
|
return isinstance(messages, list) and _is_int(flushed) and flushed >= len(messages)
|
|
|
|
|
|
def plan_pressure_evictions(
|
|
ordered_entries: Iterable[Tuple[str, Any]], *, is_evictable: Callable[[str, Any], bool],
|
|
max_evictions: int, protect_recent: int = 0,
|
|
) -> List[Tuple[str, Any]]:
|
|
"""Choose which cached sessions to shed, least-recently-used first.
|
|
|
|
``ordered_entries`` must be LRU→MRU (the cache OrderedDict ``move_to_end``s on
|
|
every hit). The batch is capped so one pass cannot stall the gateway.
|
|
``protect_recent`` is clamped to half the cache: a few huge transcripts can
|
|
exhaust the budget alone, and a fixed guard would leave nothing to shed.
|
|
"""
|
|
entries = list(ordered_entries)
|
|
if max_evictions <= 0 or not entries:
|
|
return []
|
|
protect = min(max(protect_recent, 0), len(entries) // 2)
|
|
if protect:
|
|
entries = entries[:-protect]
|
|
plan: List[Tuple[str, Any]] = []
|
|
for key, agent in entries:
|
|
if len(plan) >= max_evictions:
|
|
break
|
|
if is_evictable(key, agent):
|
|
plan.append((key, agent))
|
|
return plan
|