diff --git a/agent/context_compressor.py b/agent/context_compressor.py index fbb7e6c5e82e0..59ae723d2bee1 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -6730,6 +6730,26 @@ def _is_nonempty_user_turn(message: Dict[str, Any]) -> bool: _strip_persistence_markers(compressed) self._last_compression_made_progress = True + # A successful compaction just freed the largest allocation a long + # session ever drops (the compressed-away message dicts), which makes + # this the natural point to hand allocator pages back to the OS. + # #76905's trim lifecycle covers the gateway/TUI housekeeping loops but + # not the CLI compression path, so RSS keeps the pre-compaction + # high-water mark until exit. The helper is glibc-gated, config-gated + # and rate-limited, so this is a safe no-op elsewhere. (#70782) + try: + from hermes_cli.mem_trim import trim_memory + + trim_memory(reason="post-compression") + except Exception as exc: + # debug, not warning: sibling trim sites all log failures at + # debug, and compression must never fail because of a trim. + logger.debug( + "post-compression memory trim failed: %s: %s", + type(exc).__name__, + exc, + ) + # Batch compaction invalidates micro-compaction state: the batch # marker now holds MORE history than the in-memory rolling summary # (it summarized everything in the window, including exchanges micro diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 6f8c9b30c6485..dd1d2c07c4b79 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -230,6 +230,21 @@ def _parse_manifest(path: Path) -> CatalogEntry: scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"), ) + if t_type == "http" and a_type == "api_key": + # _build_server_config emits an Authorization header referencing + # ${MCP__API_KEY} (via _bearer_auth_headers), but install_entry + # only persists the env vars DECLARED in auth.env. Enforce the naming + # contract at parse time, or a manifest declaring e.g. N8N_API_KEY + # would install cleanly yet send a literal-placeholder header (401) + # at connect time. + from hermes_cli.mcp_config import _env_key_for_server + + _required_key = _env_key_for_server(name) + if not any(spec.name == _required_key for spec in env_list): + raise CatalogError( + f"{path}: http + api_key auth requires auth.env to declare " + f"'{_required_key}' (the key the Authorization header references)" + ) tools_raw = data.get("tools") or {} if not isinstance(tools_raw, dict): @@ -506,6 +521,10 @@ def _build_server_config( cfg["url"] = t.url if entry.auth.type == "oauth": cfg["auth"] = "oauth" + elif entry.auth.type == "api_key": + from hermes_cli.mcp_config import _bearer_auth_headers + + cfg["headers"] = _bearer_auth_headers(entry.name) return cfg diff --git a/tests/agent/test_post_compression_trim.py b/tests/agent/test_post_compression_trim.py new file mode 100644 index 0000000000000..4cb1aadc39b3e --- /dev/null +++ b/tests/agent/test_post_compression_trim.py @@ -0,0 +1,66 @@ +"""A successful compaction hands allocator pages back to the OS. + +The compressed-away message dicts are the largest allocation a long session +ever frees, but Python's arena allocator keeps those pages in the process heap +— RSS retains the pre-compaction high-water mark until exit. #76905's +trim_memory lifecycle covers the gateway/TUI housekeeping loops but not the +CLI compression path, so compress() now calls +``trim_memory(reason="post-compression")`` after a successful pass. + +trim_memory itself is glibc/Linux-gated (a fast no-op on macOS), so these +tests monkeypatch the seam rather than asserting on RSS. Salvaged in spirit +from #70782 (which reached for a bare gc.collect(); trim_memory is the +house mechanism and already wraps a collect). +""" +import hermes_cli.mem_trim as mem_trim +from agent.context_compressor import ContextCompressor + + +def _compressor(threshold_tokens: int = 24_576) -> ContextCompressor: + cc = ContextCompressor( + model="test-model", + threshold_percent=0.75, + protect_first_n=5, + protect_last_n=20, + quiet_mode=True, + config_context_length=40960, + provider="test", + ) + cc.threshold_tokens = threshold_tokens # pin; don't couple to window math + cc._generate_summary = lambda *a, **k: "Summary of earlier turns." + return cc + + +def _messages(n: int, size: int = 1500) -> list: + msgs = [{"role": "system", "content": "sys"}] + for i in range(n): + role = "user" if i % 2 == 0 else "assistant" + msgs.append({"role": role, "content": f"m{i} " + "z" * size}) + return msgs + + +def test_successful_compression_trims_memory_once(monkeypatch): + calls = [] + monkeypatch.setattr( + mem_trim, "trim_memory", lambda *a, **kw: calls.append(kw) or False + ) + + cc = _compressor() + out = cc.compress(_messages(14), current_tokens=100_000) + + assert len(out) < 15, "sanity: compaction should have made progress" + assert len(calls) == 1, "trim_memory must run exactly once per compaction" + assert calls[0].get("reason") == "post-compression" + + +def test_trim_failure_does_not_break_compression(monkeypatch): + def boom(*a, **kw): + raise RuntimeError("allocator says no") + + monkeypatch.setattr(mem_trim, "trim_memory", boom) + + cc = _compressor() + out = cc.compress(_messages(14), current_tokens=100_000) + + assert cc._last_compression_made_progress is True + assert isinstance(out, list) and out, "compress() must still return messages" diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index a5b465dd80490..29e510b29e6b8 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -143,6 +143,41 @@ def test_api_key_auth(self, catalog_dir): assert e.auth.env[1].required is False assert e.auth.env[1].secret is False + def test_http_api_key_builds_bearer_headers_template(self, catalog_dir): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import _build_server_config + + cfg = _build_server_config(_entry("demo"), None) + assert cfg["url"] == "https://mcp.example.com/sse" + assert cfg["headers"] == {"Authorization": "Bearer ${MCP_DEMO_API_KEY}"} + + def test_http_api_key_requires_matching_env_declaration(self, catalog_dir): + """http+api_key manifests must declare the env key the header references. + + install_entry only persists auth.env-declared vars; a manifest naming + its key e.g. N8N_API_KEY would install cleanly but send a literal + ${MCP_DEMO_API_KEY} placeholder at connect time (silent 401). + """ + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + path = _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import CatalogError, _parse_manifest + + with pytest.raises(CatalogError, match="MCP_DEMO_API_KEY"): + _parse_manifest(path) + @@ -193,6 +228,36 @@ def test_install_with_api_key_prompts_and_saves(self, catalog_dir, monkeypatch): assert get_env_value("DEMO_KEY") == "secret-val" assert "demo" in load_config()["mcp_servers"] + def test_install_http_api_key_writes_bearer_headers(self, catalog_dir, monkeypatch): + body = _basic_manifest( + transport={"type": "http", "url": "https://mcp.example.com/sse"}, + auth={ + "type": "api_key", + "env": [{"name": "MCP_DEMO_API_KEY", "prompt": "key", "secret": True}], + }, + ) + _write_manifest(catalog_dir, "demo", body) + + from hermes_cli import mcp_catalog + + monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val") + + from hermes_cli.mcp_catalog import install_entry + from hermes_cli.config import load_config + + install_entry(_entry("demo"), enable=True) + + server = load_config()["mcp_servers"]["demo"] + assert server["url"] == "https://mcp.example.com/sse" + assert server["headers"] == {"Authorization": "Bearer secret-val"} + # The raw file must carry the ${...} template, never the secret — + # load_config resolves it; config.yaml itself stays secret-free. + from hermes_cli.config import get_config_path + + raw = get_config_path().read_text() + assert "${MCP_DEMO_API_KEY}" in raw + assert "secret-val" not in raw +