feat(cron): cron agents now run with memory enabled like every other agent

Cron jobs were constructed with skip_memory=True and a hard 'memory'
toolset denial, so MEMORY.md/USER.md never loaded and the memory tool was
stripped even from per-job enabled_toolsets. That was inconsistent with
kanban/delegate/gateway agents (which all get memory) and forced users
into hacky bypasses.

- cron/scheduler.py: skip_memory=False on the cron AIAgent; drop 'memory'
  from _resolve_cron_disabled_toolsets; remove _strip_cron_memory_toolset
  and its call sites
- agent/agent_init.py: update stale comment referencing the cron denylist
- tests: flip pinning tests to the new contract (memory enabled, per-job
  memory toolset kept, user-level denylist still wins)
- docs: cron-internals + automate-with-cron no longer claim cron has no
  persistent memory
This commit is contained in:
Teknium
2026-08-21 03:38:02 -07:00
parent 40b4a3bfe1
commit ef04d846e9
6 changed files with 59 additions and 65 deletions
+4 -3
View File
@@ -1816,9 +1816,10 @@ def init_agent(
# skip_memory=True skips the external memory *provider*. Flush/background
# agents can still pass enabled_toolsets=["memory"] so the built-in file
# store exists and the memory tool does not fail with store=None (#65429).
# A toolset on disabled_toolsets is not a request. Cron always denylists
# memory, but the default cron toolset still names it, so an enabled-only
# check would load MEMORY.md into an auto-approve job.
# A toolset on disabled_toolsets is not a request: a caller that denylists
# memory while its default toolset still names it must not get MEMORY.md
# loaded by an enabled-only check. (Cron agents now run with
# skip_memory=False and take the normal path here.)
_enabled_toolsets = agent.enabled_toolsets or []
_disabled_toolsets = agent.disabled_toolsets or []
_memory_toolset_requested = (
+10 -27
View File
@@ -358,13 +358,9 @@ class CronPromptInjectionBlocked(Exception):
def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
"""Toolsets a cron-spawned agent must never receive.
Three toolsets are always disabled in cron context regardless of config:
Two toolsets are always disabled in cron context regardless of config:
- ``messaging`` — interactive, needs a live gateway session
- ``clarify`` — interactive, blocks waiting for user input
- ``memory`` — cron agents run with ``skip_memory=True``. The tool is
hidden, and ``memory`` is stripped from enabled_toolsets so the
built-in store is not created either (MEMORY.md would otherwise
land in the cron system prompt).
``cronjob`` is policy-denied by default (loop prevention, not a security
boundary) and config-gated: setting ``cron.allow_agent_scheduling: true``
@@ -379,9 +375,9 @@ def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
"""
cron_cfg = (cfg or {}).get("cron") or {}
if cron_cfg.get("allow_agent_scheduling"):
disabled = ["messaging", "clarify", "memory"]
disabled = ["messaging", "clarify"]
else:
disabled = ["cronjob", "messaging", "clarify", "memory"]
disabled = ["cronjob", "messaging", "clarify"]
agent_cfg = (cfg or {}).get("agent") or {}
from agent.skill_utils import parse_config_string_list
@@ -438,10 +434,6 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
3. ``None`` on any lookup failure — AIAgent loads the full default set
(legacy behavior before this change, preserved as the safety net).
``memory`` is always stripped. Cron denylists that toolset and passes
``skip_memory=True``. Leaving it in enabled_toolsets still constructs
the built-in MemoryStore and injects MEMORY.md into the job prompt.
_DEFAULT_OFF_TOOLSETS ({moa, homeassistant, rl}) are removed by
``_get_platform_tools`` for unconfigured platforms, so fresh installs
get cron WITHOUT ``moa`` by default (issue reported by Norbert —
@@ -449,12 +441,10 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
"""
per_job = job.get("enabled_toolsets")
if per_job:
return _strip_cron_memory_toolset(
_merge_mcp_into_per_job_toolsets(list(per_job), cfg or {})
)
return _merge_mcp_into_per_job_toolsets(list(per_job), cfg or {})
try:
from hermes_cli.tools_config import _get_platform_tools # lazy: avoid heavy import at cron module load
return _strip_cron_memory_toolset(sorted(_get_platform_tools(cfg or {}, "cron")))
return sorted(_get_platform_tools(cfg or {}, "cron"))
except Exception as exc:
logger.warning(
"Cron toolset resolution failed, falling back to full default toolset: %s",
@@ -463,17 +453,6 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
return None
def _strip_cron_memory_toolset(enabled: list[str] | None) -> list[str] | None:
"""Drop ``memory`` from a cron enabled-toolset list.
``None`` means "full default set" and is left alone. skip_memory=True plus
the memory denylist still keep the store off on that fallback path.
"""
if enabled is None:
return None
return [name for name in enabled if name != "memory"]
def _resolve_job_reasoning_config(job: dict, cfg: dict, model: str) -> dict | None:
"""Resolve the effective reasoning config for a cron run.
@@ -5833,7 +5812,11 @@ def run_job(
# Without a workdir, keep cwd context discovery disabled.
skip_context_files=not bool(_job_workdir),
load_soul_identity=True,
skip_memory=True, # Cron system prompts would corrupt user representations
# Memory is enabled for cron agents like any other agent run:
# MEMORY.md / USER.md load into the system prompt and the memory
# tool follows normal toolset resolution, so jobs benefit from
# (and can update) the user's persistent memory.
skip_memory=False,
skip_background_review=True, # Cron has no human-in-the-loop need for skill/memory review forks (~30K tok/event)
platform="cron",
session_id=_cron_session_id,
+22 -9
View File
@@ -7,8 +7,8 @@ default off) makes that denial opt-out-able:
- gate off / absent: byte-exact current behavior — ``cronjob`` denied.
- gate on: ``cronjob`` dropped from the base denylist; ``messaging`` and
``clarify`` (interactivity constraints) and ``memory`` (cron agents run
with skip_memory=True) are ALWAYS denied regardless of the gate.
``clarify`` (interactivity constraints) are ALWAYS denied regardless of
the gate.
- user-level ``agent.disabled_toolsets`` still layers on top, so a user who
denies ``cronjob`` globally keeps it denied even with the gate on
(per-job enabled_toolsets can never widen past the config denylist).
@@ -20,26 +20,27 @@ from cron.scheduler import _resolve_cron_disabled_toolsets
# The toolsets that must be denied in cron context no matter what the
# agent-scheduling gate says: messaging/clarify are interactive-only,
# memory stays off in cron runs (skip_memory=True, toolset denylisted).
ALWAYS_DISABLED = ["messaging", "clarify", "memory"]
# agent-scheduling gate says: messaging/clarify are interactive-only.
# ``memory`` is intentionally NOT here — cron agents get memory like any
# other agent run.
ALWAYS_DISABLED = ["messaging", "clarify"]
class TestGateOffDefault:
def test_empty_config_denies_cronjob(self):
assert _resolve_cron_disabled_toolsets({}) == [
"cronjob", "messaging", "clarify", "memory",
"cronjob", "messaging", "clarify",
]
def test_none_config_denies_cronjob(self):
assert _resolve_cron_disabled_toolsets(None) == [
"cronjob", "messaging", "clarify", "memory",
"cronjob", "messaging", "clarify",
]
def test_cron_section_present_but_gate_absent(self):
cfg = {"cron": {"preflight": True}}
assert _resolve_cron_disabled_toolsets(cfg) == [
"cronjob", "messaging", "clarify", "memory",
"cronjob", "messaging", "clarify",
]
def test_explicit_false_matches_default(self):
@@ -60,12 +61,18 @@ class TestGateOn:
disabled = _resolve_cron_disabled_toolsets(cfg)
assert "cronjob" not in disabled
def test_interactivity_and_memory_denials_survive_the_gate(self):
def test_interactivity_denials_survive_the_gate(self):
cfg = {"cron": {"allow_agent_scheduling": True}}
disabled = _resolve_cron_disabled_toolsets(cfg)
for name in ALWAYS_DISABLED:
assert name in disabled
def test_memory_not_denied(self):
# Cron agents run with memory enabled like any other agent run
# (skip_memory=False); the toolset must not be policy-denied.
for cfg in ({}, {"cron": {"allow_agent_scheduling": True}}):
assert "memory" not in _resolve_cron_disabled_toolsets(cfg)
def test_user_denylist_wins_over_gate(self):
# A user who denies cronjob in agent.disabled_toolsets keeps it
# denied even with the gate on — the gate only removes the built-in
@@ -94,6 +101,12 @@ class TestUserLayerUnchanged:
# No duplicate when the user names an already-denied toolset.
assert disabled.count("cronjob") == 1
def test_user_can_still_deny_memory_for_cron(self):
# Memory is no longer policy-denied, but a user-level denylist
# entry still applies to cron runs.
cfg = {"agent": {"disabled_toolsets": ["memory"]}}
assert "memory" in _resolve_cron_disabled_toolsets(cfg)
def test_blank_and_whitespace_entries_ignored(self):
cfg = {
"cron": {"allow_agent_scheduling": True},
+19 -24
View File
@@ -118,23 +118,22 @@ class TestPerJobToolsetMcpMerge:
assert m_platform.call_args[0][1] == "cron"
assert set(result) == set(sentinel)
def test_resolver_strips_memory_from_per_job_list(self):
def test_resolver_keeps_memory_in_per_job_list(self):
result = _resolve_cron_enabled_toolsets(
{"enabled_toolsets": ["memory", "file"]},
{"mcp_servers": {}},
)
assert "memory" not in result
assert "memory" in result
assert "file" in result
def test_resolver_strips_memory_from_platform_fallback(self):
def test_resolver_keeps_memory_from_platform_fallback(self):
job = {"enabled_toolsets": None}
with patch(
"hermes_cli.tools_config._get_platform_tools",
return_value={"web", "memory", "file"},
):
result = _resolve_cron_enabled_toolsets(job, {})
assert result == ["file", "web"]
assert "memory" not in result
assert result == ["file", "memory", "web"]
class TestResolveOrigin:
@@ -628,15 +627,15 @@ class TestRunJobSessionPersistence:
yield fake_db, mock_agent_cls
def test_run_job_memory_toolset_disabled_in_cron(self, tmp_path):
"""memory toolset must be disabled in cron sessions — issue #38129.
def test_run_job_memory_enabled_in_cron(self, tmp_path):
"""Cron agents get memory like any other agent run.
Cron agents are constructed with skip_memory=True. The memory tool
stays off the schema, and memory is stripped from enabled_toolsets
so MEMORY.md is not loaded into the job prompt.
skip_memory=False and the memory toolset is not policy-denied, so
MEMORY.md/USER.md load and the memory tool follows normal toolset
resolution.
"""
job = {
"id": "memory-hide-job",
"id": "memory-enabled-job",
"name": "test",
"prompt": "hello",
}
@@ -644,17 +643,13 @@ class TestRunJobSessionPersistence:
run_job(job)
kwargs = mock_agent_cls.call_args.kwargs
assert "memory" in (kwargs["disabled_toolsets"] or []), (
"memory toolset should be disabled in cron to match skip_memory=True"
assert kwargs["skip_memory"] is False
assert "memory" not in (kwargs["disabled_toolsets"] or []), (
"memory toolset must not be policy-denied in cron"
)
def test_run_job_disables_memory_even_when_per_job_enables_it(self, tmp_path):
"""Cron runs pass skip_memory=True, so memory must not be exposed.
A cron job can name the memory toolset in enabled_toolsets. The
resolver drops it, and the denylist still lists it, so the model
never gets the tool and init never builds MemoryStore.
"""
def test_run_job_keeps_per_job_memory_toolset(self, tmp_path):
"""A per-job enabled_toolsets naming memory keeps it."""
job = {
"id": "memory-toolset-job",
"name": "test",
@@ -665,10 +660,10 @@ class TestRunJobSessionPersistence:
run_job(job)
kwargs = mock_agent_cls.call_args.kwargs
assert kwargs["skip_memory"] is True
assert kwargs["enabled_toolsets"] == ["file"]
assert "memory" not in (kwargs["enabled_toolsets"] or [])
assert "memory" in kwargs["disabled_toolsets"]
assert kwargs["skip_memory"] is False
assert "memory" in (kwargs["enabled_toolsets"] or [])
assert "file" in (kwargs["enabled_toolsets"] or [])
assert "memory" not in kwargs["disabled_toolsets"]
def test_tick_skips_due_jobs_while_dispatch_is_paused(self, tmp_path):
"""The drain gate runs before advancing a due job's schedule."""
@@ -176,7 +176,9 @@ agent↔Nous wire contract lives in `docs/chronos-managed-cron-contract.md`.
Each cron job runs in a completely fresh agent session:
- No conversation history from previous runs
- No memory of previous cron executions (unless persisted to memory/files)
- No memory of previous cron executions (persistent memory — MEMORY.md /
USER.md — does load, like any other agent run, so durable preferences and
facts carry over; per-run conversation context does not)
- The prompt must be self-contained — cron jobs cannot ask clarifying questions
- The `cronjob` toolset is disabled (recursion guard)
+1 -1
View File
@@ -123,7 +123,7 @@ Otherwise, provide a concise summary of the activity." --name "Repo watcher" --d
```
:::warning Self-Contained Prompts
Notice how the prompt includes the exact `gh` commands. The cron agent has no memory of previous runs or your preferences — spell everything out.
Notice how the prompt includes the exact `gh` commands. The cron agent has no conversation history from previous runs — spell everything out. (Persistent memory does load, so durable preferences saved to MEMORY.md carry over, but don't rely on it for job-critical details.)
:::
---