Skip to content
Closed
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
13 changes: 11 additions & 2 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ def init_agent(
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
skip_local_memory: bool = False,
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
Expand Down Expand Up @@ -1660,14 +1661,22 @@ def init_agent(
agent._memory_store.load_from_disk()
except Exception:
pass # Memory is optional -- don't break agent init


elif not skip_memory:
# Provider-only path still needs mem_config for the provider name.
try:
mem_config = _agent_cfg.get("memory", {}) or {}
except Exception:
mem_config = {}

# Memory provider plugin (external — one at a time, alongside built-in)
# Reads memory.provider from config to select which plugin to activate.
# Gated only by ``skip_memory`` so ``skip_local_memory=True`` can still
# enable providers (cron provider-only opt-in).
agent._memory_manager = None
if not skip_memory:
try:
if not mem_config:
mem_config = _agent_cfg.get("memory", {}) or {}
_mem_provider_name = mem_config.get("provider", "") if mem_config else ""

if _mem_provider_name and _mem_provider_name.strip():
Expand Down
2 changes: 2 additions & 0 deletions contributors/emails/seraphine@Seraphines-Mac-Studio.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Bartok9
# Seraphine Mac Studio local email on Bartok9 PR tips (per-PR attribution; Teknium/Daniel 2026-08-01)
23 changes: 22 additions & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2750,6 +2750,23 @@ def _guard_job_credential_exfil(job: dict) -> None:
raise RuntimeError(f"Cron job '{job_id}' blocked for safety: {err}")



def _resolve_cron_skip_memory(cfg: dict) -> bool:
"""Whether cron runs should skip memory providers/tools.

Defaults to True. Operators can set ``cron.skip_memory: false`` so
external memory providers (e.g. mem0) become usable in cron jobs (#9763).

Local MEMORY.md / USER.md injection stays off even when this returns
False: ``run_job`` always passes ``skip_local_memory=True`` so cron
system prompts cannot corrupt on-disk user representations (005e0ec).
"""
cron_cfg = cfg.get("cron") if isinstance(cfg, dict) else None
if not isinstance(cron_cfg, dict):
return True
return bool(cron_cfg.get("skip_memory", True))


def run_job(
job: dict, *, defer_agent_teardown: Optional[list] = None
) -> tuple[bool, str, str, Optional[str]]:
Expand Down Expand Up @@ -3501,7 +3518,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
# Default True. ``cron.skip_memory: false`` enables *provider-only*
# memory (mem0/Honcho/etc.). Local MEMORY.md/USER.md stays skipped
# so cron prompts cannot corrupt disk identity (005e0ec / #52897).
skip_memory=_resolve_cron_skip_memory(_cfg),
skip_local_memory=True,
platform="cron",
session_id=_cron_session_id,
session_db=_session_db,
Expand Down
2 changes: 2 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ def __init__(
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
skip_local_memory: bool = False,
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
Expand Down Expand Up @@ -576,6 +577,7 @@ def __init__(
skip_context_files=skip_context_files,
load_soul_identity=load_soul_identity,
skip_memory=skip_memory,
skip_local_memory=skip_local_memory,
session_db=session_db,
parent_session_id=parent_session_id,
iteration_budget=iteration_budget,
Expand Down
156 changes: 156 additions & 0 deletions tests/cron/test_scheduler_skip_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Tests for cron scheduler skip_memory configuration.

Regression for issue #9763: skip_memory was hardcoded to True in
cron/scheduler.py, making external memory providers (e.g. mem0) unusable
in cron jobs. Resolution is centralized in ``_resolve_cron_skip_memory``.

Cron opt-in is *provider-only*: ``run_job`` always passes
``skip_local_memory=True`` so MEMORY.md/USER.md injection stays off even
when ``cron.skip_memory: false`` (#52897 teknium review / 005e0ec).
"""

from cron.scheduler import _resolve_cron_skip_memory


class TestResolveCronSkipMemory:
def test_defaults_to_true_when_no_cron_section(self):
assert _resolve_cron_skip_memory({}) is True

def test_defaults_to_true_when_skip_memory_missing(self):
assert _resolve_cron_skip_memory({"cron": {}}) is True

def test_false_when_configured(self):
assert _resolve_cron_skip_memory({"cron": {"skip_memory": False}}) is False

def test_true_when_explicitly_set(self):
assert _resolve_cron_skip_memory({"cron": {"skip_memory": True}}) is True

def test_other_cron_settings_do_not_affect_default(self):
assert _resolve_cron_skip_memory({"cron": {"timeout": 300}}) is True

def test_tolerates_non_dict_cron_section(self):
assert _resolve_cron_skip_memory({"cron": None}) is True
assert _resolve_cron_skip_memory(None) is True


class TestRunJobSkipMemoryWiring:
"""Prove run_job forwards resolved skip_memory + always skip_local_memory.

Uses the FakeAgent constructor-kwargs capture seam from
tests/cron/test_cron_workdir.py (teknium #52897 review).

run_job loads cron config from HERMES_HOME/config.yaml (not load_config),
so tests stage a temp hermes home with the desired yaml.
"""

@staticmethod
def _install_stubs(monkeypatch, observed: dict, tmp_path, cron_cfg: dict):
import sys
from pathlib import Path
import cron.scheduler as sched

class FakeAgent:
def __init__(self, **kwargs):
observed["skip_memory"] = kwargs.get("skip_memory")
observed["skip_local_memory"] = kwargs.get("skip_local_memory")

def run_conversation(self, *_a, **_kw):
return {"final_response": "done", "messages": []}

def get_activity_summary(self):
return {"seconds_since_activity": 0.0}

fake_mod = type(sys)("run_agent")
fake_mod.AIAgent = FakeAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)

from hermes_cli import runtime_provider as _rtp

monkeypatch.setattr(
_rtp,
"resolve_runtime_provider",
lambda **_kw: {
"provider": "test",
"api_key": "k",
"base_url": "http://test.local",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr(
sched, "_build_job_prompt", lambda job, prerun_script=None: "hi"
)
monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
monkeypatch.setattr(
sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None
)
monkeypatch.setattr(
sched, "_resolve_cron_disabled_toolsets", lambda cfg: None
)
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")

import dotenv

monkeypatch.setattr(dotenv, "load_dotenv", lambda *_a, **_kw: True)

home = Path(tmp_path)
home.mkdir(parents=True, exist_ok=True)
cfg_path = home / "config.yaml"
# Minimal yaml; avoid importing pyyaml if string write is enough
lines = ["model: test-model", "cron:"]
if not cron_cfg:
lines.append(" {}")
else:
for k, v in cron_cfg.items():
rendered = (
"true"
if v is True
else "false"
if v is False
else repr(v)
)
lines.append(f" {k}: {rendered}")
cfg_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
monkeypatch.setattr(sched, "_get_hermes_home", lambda: home)

def test_run_job_default_skip_memory_true_and_skip_local(
self, tmp_path, monkeypatch
):
import cron.scheduler as sched

observed = {}
self._install_stubs(monkeypatch, observed, tmp_path, {})

ok, *_ = sched.run_job(
{
"id": "skipmem-default",
"name": "skip memory default",
"prompt": "hi",
"model": "test-model",
}
)
assert ok is True
assert observed["skip_memory"] is True
assert observed["skip_local_memory"] is True

def test_run_job_provider_only_opt_in(self, tmp_path, monkeypatch):
import cron.scheduler as sched

observed = {}
self._install_stubs(
monkeypatch, observed, tmp_path, {"skip_memory": False}
)

ok, *_ = sched.run_job(
{
"id": "skipmem-opt-in",
"name": "provider only",
"prompt": "hi",
"model": "test-model",
}
)
assert ok is True
# Provider-only: external memory providers may run…
assert observed["skip_memory"] is False
# …but local MEMORY.md/USER.md injection must stay off.
assert observed["skip_local_memory"] is True
2 changes: 1 addition & 1 deletion website/docs/user-guide/features/spotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ hermes cron add \
- **An active device must exist when the cron fires.** If no Spotify client is running (phone/desktop/Connect speaker), playback actions return `403 no active device`. For morning playlists, the trick is to target a device that's always on (Sonos, Echo, a smart speaker) rather than your phone.
- **Premium required for anything that mutates playback** — play, pause, skip, volume, transfer. Read-only cron jobs (scheduled "email me my recently played tracks") work fine on Free.
- **The cron agent inherits your active toolsets.** Spotify must be enabled in `hermes tools` for the cron session to see the Spotify tools.
- **Cron jobs run with `skip_memory=True`** so they don't write to your memory store.
- **Cron jobs skip memory by default** (`cron.skip_memory: true`) so they don't touch your memory store. Set `cron.skip_memory: false` to allow external memory providers only; local MEMORY.md/USER.md injection stays off for cron either way.

Full cron reference: [Cron Jobs](./cron).

Expand Down
Loading