146 lines
6.3 KiB
Python
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
|