Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions hermes_cli/mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>_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):
Expand Down Expand Up @@ -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


Expand Down
66 changes: 66 additions & 0 deletions tests/agent/test_post_compression_trim.py
Original file line number Diff line number Diff line change
@@ -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"
65 changes: 65 additions & 0 deletions tests/hermes_cli/test_mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)




Expand Down Expand Up @@ -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




Expand Down
Loading