Files
hermes-agent/tests/cron/test_cron_memory_contract.py

146 lines
6.3 KiB
Python

"""Contract pin: cron <-> persistent-memory loading.
This contract FLIPPED TWICE in August 2026 and must never flip silently again:
* #91269 reported "cron loads MEMORY.md even though skip_memory is on".
* PR #91384 flipped cron to ``skip_memory=True`` and denylisted the
``memory`` toolset ("do not load MEMORY.md into scheduled jobs").
* PR #91447 flipped it BACK: "cron jobs now load and update persistent
memory like every other agent" — ``skip_memory=False`` at the scheduler's
AIAgent construction site, ``memory`` removed from the cron denylist,
and ``agent/agent_init.py`` clarified that ``skip_memory`` skips the
*external memory provider* path (built-in MEMORY.md/USER.md store follows
the normal ``not skip_memory or memory-toolset-requested`` rule).
CURRENT INTENDED MATRIX (as of PR #91447, pinned here):
default cron job -> skip_memory=False; MEMORY.md/USER.md load into
the system prompt; ``memory`` toolset follows
normal resolution (NOT policy-denied).
per-job enabled_toolsets -> naming ``memory`` keeps it; skip_memory stays
False.
config.yaml
agent.disabled_toolsets:
[memory] -> the ONLY off-switch: ``memory`` lands in the
cron agent's disabled_toolsets (tool denied,
and agent_init treats a denylisted toolset as
not-requested). skip_memory itself is NOT a
per-job/config toggle — the scheduler always
passes False.
ANY future flip of this behavior MUST consciously edit this test and cite
the issue/PR that decided the flip in the module docstring above, extending
the flip history. Do not "fix" a failure here by inverting an assertion
without that citation.
Tests drive the REAL ``cron.scheduler.run_job`` path and capture the actual
kwargs the scheduler passes to AIAgent (patched at ``run_agent.AIAgent``,
matching tests/cron/test_scheduler.py's pattern). The ON direction (default
skip_memory=False, memory not denylisted, per-job memory toolset kept) is
already pinned by tests/cron/test_scheduler.py::test_run_job_*memory*; this
module pins the OFF direction and the "no per-job knob" rule.
"""
from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch
from cron.scheduler import run_job
@contextlib.contextmanager
def _run_job_patches(tmp_path):
"""Patch bundle so run_job runs offline; yields (fake_db, mock_agent_cls).
Mirrors tests/cron/test_scheduler.py::_run_job_patches — every patch is
entered via one ExitStack so none can be silently dropped.
``cron.scheduler._hermes_home`` is pointed at ``tmp_path`` so run_job's
config load reads ``tmp_path/config.yaml`` (write one to exercise config
toggles).
"""
fake_db = MagicMock()
fake_db.get_compression_tip.side_effect = lambda session_id: session_id
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
base = [
patch("cron.scheduler._hermes_home", tmp_path),
patch("cron.scheduler_delivery._resolve_origin", return_value=None),
patch("hermes_cli.env_loader.load_hermes_dotenv"),
patch("hermes_cli.env_loader.reset_secret_source_cache"),
patch("hermes_state_registry.acquire", return_value=fake_db),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "test-key",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
),
patch("run_agent.AIAgent", return_value=mock_agent),
]
with contextlib.ExitStack() as stack:
entered = [stack.enter_context(cm) for cm in base]
yield fake_db, entered[-1]
class TestCronMemoryContractOn:
"""Direction (a): default cron agents GET persistent memory (#91447)."""
def test_resolver_denylist_has_no_memory_entry(self):
"""_resolve_cron_disabled_toolsets({}) itself never emits 'memory'."""
from cron.scheduler import _resolve_cron_disabled_toolsets
assert "memory" not in _resolve_cron_disabled_toolsets({})
assert "memory" not in _resolve_cron_disabled_toolsets(
{"cron": {"allow_agent_scheduling": True}}
)
class TestCronMemoryContractOff:
"""Direction (b): the supported OFF switch stays off."""
def test_config_disabled_toolsets_denies_memory(self, tmp_path):
"""agent.disabled_toolsets: [memory] in config.yaml denies the toolset.
This is the intended user-level off-switch after #91447: the user
denylist layers onto cron's base denylist (#25752), so the memory
tool is denied AND agent_init treats a denylisted toolset as
not-requested. A per-job enabled_toolsets cannot widen past it.
"""
(tmp_path / "config.yaml").write_text(
"agent:\n disabled_toolsets:\n - memory\n"
)
job = {
"id": "mem-contract-off",
"name": "t",
"prompt": "hi",
"enabled_toolsets": ["memory", "file"],
}
with _run_job_patches(tmp_path) as (_db, agent_cls):
run_job(job)
kwargs = agent_cls.call_args.kwargs
assert "memory" in (kwargs["disabled_toolsets"] or []), (
"config.yaml agent.disabled_toolsets must propagate 'memory' into "
"the cron agent's denylist — the OFF direction of the contract"
)
def test_skip_memory_is_not_a_per_job_knob(self, tmp_path):
"""No per-job field flips skip_memory: the scheduler always passes False.
Guards against a partial re-flip where some job shape quietly gets
#91384 behavior back. A field named skip_memory on the job dict is
ignored by the construction site.
"""
job = {
"id": "mem-contract-noknob",
"name": "t",
"prompt": "hi",
"skip_memory": True, # not a supported job field; must be ignored
}
with _run_job_patches(tmp_path) as (_db, agent_cls):
run_job(job)
kwargs = agent_cls.call_args.kwargs
assert kwargs["skip_memory"] is False