diff --git a/cron/jobs.py b/cron/jobs.py index 5e493ae3f7..93098bd86b 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -420,7 +420,7 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: def create_job( - prompt: str, + prompt: Optional[str], schedule: str, name: Optional[str] = None, repeat: Optional[int] = None, @@ -435,12 +435,14 @@ def create_job( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + no_agent: bool = False, ) -> Dict[str, Any]: """ Create a new cron job. Args: - prompt: The prompt to run (must be self-contained, or a task instruction when skill is set) + prompt: The prompt to run (must be self-contained, or a task instruction when skill is set). + Ignored when ``no_agent=True`` except as an optional name hint. schedule: Schedule string (see parse_schedule) name: Optional friendly name repeat: How many times to run (None = forever, 1 = once) @@ -451,21 +453,33 @@ def create_job( model: Optional per-job model override provider: Optional per-job provider override base_url: Optional per-job base URL override - script: Optional path to a Python script whose stdout is injected into the - prompt each run. The script runs before the agent turn, and its output - is prepended as context. Useful for data collection / change detection. + script: Optional path to a script whose stdout feeds the job. With + ``no_agent=True`` the script IS the job — its stdout is + delivered verbatim. Without ``no_agent``, its stdout is + injected into the agent's prompt as context (data-collection / + change-detection pattern). Paths resolve under + ~/.hermes/scripts/; ``.sh`` / ``.bash`` files run via bash, + anything else via Python. context_from: Optional job ID (or list of job IDs) whose most recent output is injected into the prompt as context before each run. Useful for chaining cron jobs: job A finds data, job B processes it. enabled_toolsets: Optional list of toolset names to restrict the agent to. When set, only tools from these toolsets are loaded, reducing token overhead. When omitted, all default tools are loaded. + Ignored when ``no_agent=True``. workdir: Optional absolute path. When set, the job runs as if launched from that directory: AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory (via TERMINAL_CWD). When unset, the old behaviour is preserved (no context files injected, tools use the scheduler's cwd). + With ``no_agent=True``, ``workdir`` is still applied as the + script's cwd so relative paths inside the script behave + predictably. + no_agent: When True, skip the agent entirely — run ``script`` on schedule + and deliver its stdout directly. Empty stdout = silent (no + delivery). Requires ``script`` to be set. Ideal for classic + watchdogs and periodic alerts that don't need LLM reasoning. Returns: The created job dict @@ -499,6 +513,16 @@ def create_job( normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) + normalized_no_agent = bool(no_agent) + + # no_agent jobs are meaningless without a script — the script IS the job. + # Surface this as a clear ValueError at create time so bad configs never + # reach the scheduler. + if normalized_no_agent and not normalized_script: + raise ValueError( + "no_agent=True requires a script — with no agent and no script " + "there is nothing for the job to run." + ) # Normalize context_from: accept str or list of str, store as list or None if isinstance(context_from, str): @@ -508,7 +532,7 @@ def create_job( else: context_from = None - label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" + label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" job = { "id": job_id, "name": name or label_source[:50].strip(), @@ -519,6 +543,7 @@ def create_job( "provider": normalized_provider, "base_url": normalized_base_url, "script": normalized_script, + "no_agent": normalized_no_agent, "context_from": context_from, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), diff --git a/cron/scheduler.py b/cron/scheduler.py index cee1cb4067..81e256a329 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -576,8 +576,18 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: prevent arbitrary script execution via path traversal or absolute path injection. + Supported interpreters (chosen by file extension): + + * ``.sh`` / ``.bash`` — run with ``/bin/bash`` + * anything else — run with the current Python interpreter + (``sys.executable``), preserving the original behaviour for + Python-based pre-check and data-collection scripts. + + Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs + (the `memory-watchdog.sh` pattern) without wrapping them in Python. + Args: - script_path: Path to a Python script. Relative paths are resolved + script_path: Path to the script. Relative paths are resolved against HERMES_HOME/scripts/. Absolute and ~-prefixed paths are also validated to ensure they stay within the scripts dir. @@ -614,9 +624,19 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: script_timeout = _get_script_timeout() + # Pick an interpreter by extension. Bash for .sh/.bash, Python for + # everything else. We deliberately do NOT honour the file's own + # shebang: the scripts dir is trusted, but keeping the interpreter + # choice explicit here keeps the allowed surface small and auditable. + suffix = path.suffix.lower() + if suffix in (".sh", ".bash"): + argv = ["/bin/bash", str(path)] + else: + argv = [sys.executable, str(path)] + try: result = subprocess.run( - [sys.executable, str(path)], + argv, capture_output=True, text=True, timeout=script_timeout, @@ -830,8 +850,120 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: Returns: Tuple of (success, full_output_doc, final_response, error_message) """ + job_id = job["id"] + job_name = job["name"] + + # --------------------------------------------------------------- + # no_agent short-circuit — the script IS the job, no LLM involvement. + # --------------------------------------------------------------- + # This mirrors the classic "run a bash script on a timer, send its + # stdout to telegram" watchdog pattern. The agent path is skipped + # entirely: no AIAgent, no prompt, no tool loop, no token spend. + # + # We check this BEFORE importing run_agent / constructing SessionDB so + # a pure-script tick never pays for the agent machinery it isn't going + # to use. Keep this block self-contained. + # + # Semantics: + # - script stdout (trimmed) → delivered verbatim as the final message + # - empty stdout → silent run (no delivery, success=True) + # - non-zero exit / timeout → delivered as an error alert, success=False + # - wakeAgent=false gate → treated like empty stdout (silent), since + # the whole point of no_agent is that there + # is no agent to wake + if job.get("no_agent"): + script_path = job.get("script") + if not script_path: + err = "no_agent=True but no script is set for this job" + logger.error("Job '%s': %s", job_id, err) + return False, "", "", err + + # Apply workdir if configured — lets scripts use predictable relative + # paths. For no_agent jobs this is just the subprocess cwd (not an + # agent TERMINAL_CWD bridge). + _job_workdir = (job.get("workdir") or "").strip() or None + _prior_cwd = None + if _job_workdir and Path(_job_workdir).is_dir(): + _prior_cwd = os.getcwd() + try: + os.chdir(_job_workdir) + except OSError: + _prior_cwd = None + + try: + ok, output = _run_job_script(script_path) + finally: + if _prior_cwd is not None: + try: + os.chdir(_prior_cwd) + except OSError: + pass + + now_iso = _hermes_now().strftime("%Y-%m-%d %H:%M:%S") + + if not ok: + # Script crashed / timed out / exited non-zero. Deliver the + # error so the user knows the watchdog itself broke — silent + # failure for an alerting job is the worst-case outcome. + alert = ( + f"⚠ Cron watchdog '{job_name}' script failed\n\n" + f"{output}\n\n" + f"Time: {now_iso}" + ) + doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** script failed\n\n" + f"{output}\n" + ) + return False, doc, alert, output + + # Honour the wakeAgent gate as a silent signal — `wakeAgent: false` + # means "nothing to report this tick", same as empty stdout. + if not _parse_wake_gate(output): + logger.info( + "Job '%s' (no_agent): wakeAgent=false gate — silent run", job_id + ) + silent_doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** silent (wakeAgent=false)\n" + ) + return True, silent_doc, SILENT_MARKER, None + + if not output.strip(): + logger.info("Job '%s' (no_agent): empty stdout — silent run", job_id) + silent_doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** silent (empty output)\n" + ) + return True, silent_doc, SILENT_MARKER, None + + doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n\n" + f"---\n\n" + f"{output}\n" + ) + return True, doc, output, None + + # --------------------------------------------------------------- + # Default (LLM) path — import and construct the agent machinery now + # that we know we actually need it. Doing these imports here instead of + # at module top keeps no_agent ticks from paying for AIAgent / SessionDB + # construction costs. + # --------------------------------------------------------------- from run_agent import AIAgent - + # Initialize SQLite session store so cron job messages are persisted # and discoverable via session_search (same pattern as gateway/run.py). _session_db = None @@ -840,9 +972,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: _session_db = SessionDB() except Exception as e: logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e) - - job_id = job["id"] - job_name = job["name"] # Wake-gate: if this job has a pre-check script, run it BEFORE building # the prompt so a ``{"wakeAgent": false}`` response can short-circuit diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 78639d465a..adf4f0c092 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -93,6 +93,8 @@ def cron_list(show_all: bool = False): script = job.get("script") if script: print(f" Script: {script}") + if job.get("no_agent"): + print(f" Mode: {color('no-agent', Colors.DIM)} (script stdout delivered directly)") workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") @@ -172,6 +174,7 @@ def cron_create(args): skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + no_agent=getattr(args, "no_agent", False) or None, ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -184,6 +187,8 @@ def cron_create(args): job_data = result.get("job", {}) if job_data.get("script"): print(f" Script: {job_data['script']}") + if job_data.get("no_agent"): + print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") print(f" Next run: {result['next_run_at']}") @@ -225,6 +230,7 @@ def cron_edit(args): skills=final_skills, script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + no_agent=getattr(args, "no_agent", None), ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -240,6 +246,8 @@ def cron_edit(args): print(" Skills: none") if updated.get("script"): print(f" Script: {updated['script']}") + if updated.get("no_agent"): + print(" Mode: no-agent (script stdout delivered directly)") if updated.get("workdir"): print(f" Workdir: {updated['workdir']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4f15cd26d5..ac7da65a23 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8678,7 +8678,24 @@ def main(): ) cron_create.add_argument( "--script", - help="Path to a Python script whose stdout is injected into the prompt each run", + help=( + "Path to a script under ~/.hermes/scripts/. Default mode: " + "script stdout is injected into the agent's prompt each run. " + "With --no-agent: the script IS the job and its stdout is " + "delivered verbatim. .sh/.bash files run via bash, everything " + "else via Python." + ), + ) + cron_create.add_argument( + "--no-agent", + dest="no_agent", + action="store_true", + default=False, + help=( + "Skip the LLM entirely — run --script on schedule and deliver " + "its stdout directly. Empty stdout = silent. Classic watchdog " + "pattern (memory alerts, disk alerts, CI pings)." + ), ) cron_create.add_argument( "--workdir", @@ -8720,7 +8737,29 @@ def main(): ) cron_edit.add_argument( "--script", - help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.", + help=( + "Path to a script under ~/.hermes/scripts/. Pass empty string to clear. " + "With --no-agent the script IS the job; otherwise its stdout is " + "injected into the agent's prompt each run." + ), + ) + cron_edit.add_argument( + "--no-agent", + dest="no_agent", + action="store_const", + const=True, + default=None, + help=( + "Enable no-agent mode on this job (requires --script or an " + "existing script on the job)." + ), + ) + cron_edit.add_argument( + "--agent", + dest="no_agent", + action="store_const", + const=False, + help="Disable no-agent mode on this job (reverts to LLM-driven execution).", ) cron_edit.add_argument( "--workdir", diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py new file mode 100644 index 0000000000..117cb8c7d9 --- /dev/null +++ b/tests/cron/test_cron_no_agent.py @@ -0,0 +1,332 @@ +"""Tests for cronjob no_agent mode — script-driven jobs that skip the LLM. + +Covers: + +* ``create_job(no_agent=True)`` shape, validation, and serialization. +* ``cronjob(action='create', no_agent=True)`` tool-level validation. +* ``cronjob(action='update')`` flipping no_agent on/off. +* ``scheduler.run_job`` short-circuit path: success/silent/failure. +* Shell script support in ``_run_job_script`` (.sh runs via bash). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def hermes_env(tmp_path, monkeypatch): + """Isolate HERMES_HOME for each test so jobs/scripts don't leak.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "scripts").mkdir() + (home / "cron").mkdir() + + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Reload modules that cache get_hermes_home() at import time. + import importlib + import hermes_constants + importlib.reload(hermes_constants) + import cron.jobs + importlib.reload(cron.jobs) + import cron.scheduler + importlib.reload(cron.scheduler) + + return home + + +# --------------------------------------------------------------------------- +# create_job / update_job: data-layer semantics +# --------------------------------------------------------------------------- + + +def test_create_job_no_agent_requires_script(hermes_env): + from cron.jobs import create_job + + with pytest.raises(ValueError, match="no_agent=True requires a script"): + create_job(prompt=None, schedule="every 5m", no_agent=True) + + +def test_create_job_no_agent_stores_field(hermes_env): + from cron.jobs import create_job + + script_path = hermes_env / "scripts" / "watchdog.sh" + script_path.write_text("#!/bin/bash\necho hi\n") + + job = create_job( + prompt=None, + schedule="every 5m", + script="watchdog.sh", + no_agent=True, + deliver="local", + ) + assert job["no_agent"] is True + assert job["script"] == "watchdog.sh" + # Prompt can be empty/None for no_agent jobs. + assert job["prompt"] in (None, "") + + +def test_create_job_default_is_not_no_agent(hermes_env): + from cron.jobs import create_job + + job = create_job(prompt="say hi", schedule="every 5m", deliver="local") + assert job.get("no_agent") is False + + +def test_update_job_roundtrips_no_agent_flag(hermes_env): + from cron.jobs import create_job, update_job, get_job + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + job = create_job(prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local") + + update_job(job["id"], {"no_agent": False}) + reloaded = get_job(job["id"]) + assert reloaded["no_agent"] is False + + update_job(job["id"], {"no_agent": True}) + reloaded = get_job(job["id"]) + assert reloaded["no_agent"] is True + + +# --------------------------------------------------------------------------- +# cronjob tool: API-layer validation +# --------------------------------------------------------------------------- + + +def test_cronjob_tool_create_no_agent_without_script_errors(hermes_env): + from tools.cronjob_tools import cronjob + + result = json.loads( + cronjob(action="create", schedule="every 5m", no_agent=True, deliver="local") + ) + assert result.get("success") is False + assert "no_agent=True requires a script" in result.get("error", "") + + +def test_cronjob_tool_create_no_agent_with_script_succeeds(hermes_env): + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho alert\n") + + result = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="alert.sh", + no_agent=True, + deliver="local", + ) + ) + assert result.get("success") is True + assert result["job"]["no_agent"] is True + assert result["job"]["script"] == "alert.sh" + + +def test_cronjob_tool_update_toggles_no_agent(hermes_env): + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + + created = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="w.sh", + no_agent=True, + deliver="local", + ) + ) + job_id = created["job_id"] + + off = json.loads(cronjob(action="update", job_id=job_id, no_agent=False, prompt="run")) + assert off["success"] is True + assert off["job"].get("no_agent") in (False, None) + + on = json.loads(cronjob(action="update", job_id=job_id, no_agent=True)) + assert on["success"] is True + assert on["job"]["no_agent"] is True + + +def test_cronjob_tool_update_no_agent_without_script_errors(hermes_env): + """Flipping no_agent=True on a job that has no script must fail.""" + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob(action="create", schedule="every 5m", prompt="do a thing", deliver="local") + ) + job_id = created["job_id"] + + result = json.loads(cronjob(action="update", job_id=job_id, no_agent=True)) + assert result.get("success") is False + assert "without a script" in result.get("error", "") + + +def test_cronjob_tool_create_does_not_require_prompt_when_no_agent(hermes_env): + """The 'prompt or skill required' rule is relaxed for no_agent jobs.""" + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + + result = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="w.sh", + no_agent=True, + deliver="local", + ) + ) + assert result.get("success") is True + + +# --------------------------------------------------------------------------- +# scheduler.run_job: short-circuit behavior +# --------------------------------------------------------------------------- + + +def test_run_job_no_agent_success_returns_script_stdout(hermes_env): + """Happy path: script exits 0 with output, delivered verbatim.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert error is None + assert "RAM 92% on host" in final_response + assert "RAM 92% on host" in doc + + +def test_run_job_no_agent_empty_output_is_silent(hermes_env): + """Empty stdout → SILENT_MARKER, which suppresses delivery downstream.""" + from cron.jobs import create_job + from cron.scheduler import run_job, SILENT_MARKER + + script_path = hermes_env / "scripts" / "quiet.sh" + script_path.write_text("#!/bin/bash\n# nothing to say\n") + + job = create_job( + prompt=None, schedule="every 5m", script="quiet.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert error is None + assert final_response == SILENT_MARKER + + +def test_run_job_no_agent_wake_gate_is_silent(hermes_env): + """wakeAgent=false gate in stdout triggers a silent run.""" + from cron.jobs import create_job + from cron.scheduler import run_job, SILENT_MARKER + + script_path = hermes_env / "scripts" / "gated.sh" + script_path.write_text('#!/bin/bash\necho \'{"wakeAgent": false}\'\n') + + job = create_job( + prompt=None, schedule="every 5m", script="gated.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert final_response == SILENT_MARKER + + +def test_run_job_no_agent_script_failure_delivers_error(hermes_env): + """Non-zero exit → success=False, error alert is the delivered message.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "broken.sh" + script_path.write_text("#!/bin/bash\necho oops >&2\nexit 3\n") + + job = create_job( + prompt=None, schedule="every 5m", script="broken.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is False + assert error is not None + assert "oops" in final_response or "exited with code 3" in final_response + assert "Cron watchdog" in final_response # alert header + + +def test_run_job_no_agent_never_invokes_aiagent(hermes_env): + """no_agent jobs must NOT import/construct the AIAgent.""" + from cron.jobs import create_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho alert\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + + with patch("run_agent.AIAgent") as ai_mock: + from cron.scheduler import run_job + + run_job(job) + + ai_mock.assert_not_called() + + +# --------------------------------------------------------------------------- +# _run_job_script: shell-script support +# --------------------------------------------------------------------------- + + +def test_run_job_script_shell_script_runs_via_bash(hermes_env): + """.sh files should execute under /bin/bash even without a shebang line.""" + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "shelly.sh" + # No shebang — relies on the interpreter-by-extension rule. + script_path.write_text('echo "shell: $BASH_VERSION" | head -c 7\n') + + ok, output = _run_job_script("shelly.sh") + assert ok is True + assert output.startswith("shell:") + + +def test_run_job_script_bash_extension_also_runs_via_bash(hermes_env): + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "thing.bash" + script_path.write_text('printf "via bash\\n"\n') + + ok, output = _run_job_script("thing.bash") + assert ok is True + assert output == "via bash" + + +def test_run_job_script_python_still_runs_via_python(hermes_env): + """Regression: .py files must keep running via sys.executable.""" + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "py.py" + script_path.write_text("import sys\nprint(f'python {sys.version_info.major}')\n") + + ok, output = _run_job_script("py.py") + assert ok is True + assert output.startswith("python ") + + +def test_run_job_script_path_traversal_still_blocked(hermes_env): + """Security regression: shell-script support must NOT loosen containment.""" + from cron.scheduler import _run_job_script + + # Absolute path outside the scripts dir should be rejected. + ok, output = _run_job_script("/etc/passwd") + assert ok is False + assert "Blocked" in output or "outside" in output diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index ec4b41b3c7..5e9ffa51ea 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -245,6 +245,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: } if job.get("script"): result["script"] = job["script"] + if job.get("no_agent"): + result["no_agent"] = True if job.get("enabled_toolsets"): result["enabled_toolsets"] = job["enabled_toolsets"] if job.get("workdir"): @@ -271,6 +273,7 @@ def cronjob( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + no_agent: Optional[bool] = None, task_id: str = None, ) -> str: """Unified cron job management tool.""" @@ -283,8 +286,22 @@ def cronjob( if not schedule: return tool_error("schedule is required for create", success=False) canonical_skills = _canonical_skills(skill, skills) - if not prompt and not canonical_skills: - return tool_error("create requires either prompt or at least one skill", success=False) + _no_agent = bool(no_agent) + # Job-shape validation differs by mode: + # - no_agent=True → script is the job; prompt/skills are optional + # (and irrelevant to execution). + # - no_agent=False (default) → at least one of prompt/skills must + # be set, same as before. + if _no_agent: + if not script: + return tool_error( + "create with no_agent=True requires a script — " + "the script is the job.", + success=False, + ) + else: + if not prompt and not canonical_skills: + return tool_error("create requires either prompt or at least one skill", success=False) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: @@ -323,6 +340,7 @@ def cronjob( context_from=context_from, enabled_toolsets=enabled_toolsets or None, workdir=_normalize_optional_job_value(workdir), + no_agent=_no_agent, ) return json.dumps( { @@ -436,6 +454,20 @@ def cronjob( # Empty string clears the field (restores old behaviour); # otherwise pass raw — update_job() validates / normalizes. updates["workdir"] = _normalize_optional_job_value(workdir) or None + if no_agent is not None: + # Toggling no_agent on/off at update time. If flipping to True, + # we need a script to already exist on the job (or be part of + # the same update) — otherwise the next tick would error out. + target_no_agent = bool(no_agent) + if target_no_agent: + effective_script = updates.get("script") if "script" in updates else job.get("script") + if not effective_script: + return tool_error( + "Cannot set no_agent=True on a job without a script. " + "Set `script` in the same update, or on the job first.", + success=False, + ) + updates["no_agent"] = target_no_agent if repeat is not None: # Normalize: treat 0 or negative as None (infinite) normalized_repeat = None if repeat <= 0 else repeat @@ -533,7 +565,25 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "script": { "type": "string", - "description": f"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear." + "description": f"Optional path to a script that runs each tick. In the default mode its stdout is injected into the agent's prompt as context (data-collection / change-detection pattern). With no_agent=True, the script IS the job and its stdout is delivered verbatim (classic watchdog pattern). Relative paths resolve under {display_hermes_home()}/scripts/. ``.sh``/``.bash`` extensions run via bash, everything else via Python. On update, pass empty string to clear." + }, + "no_agent": { + "type": "boolean", + "default": False, + "description": ( + "Default: False (LLM-driven job — the agent runs the prompt each tick). " + "Set True to skip the LLM entirely: the scheduler just runs ``script`` on schedule and delivers its stdout verbatim. No tokens, no agent loop, no model override honoured. " + "\n\n" + "REQUIREMENTS when True: ``script`` MUST be set (``prompt`` and ``skills`` are ignored). " + "\n\n" + "DELIVERY SEMANTICS when True: " + "(a) non-empty stdout is sent verbatim as the message; " + "(b) EMPTY stdout means SILENT — nothing is sent to the user and they won't see anything happened, so design your script to stay quiet when there's nothing to report (the watchdog pattern); " + "(c) non-zero exit / timeout sends an error alert so a broken watchdog can't fail silently. " + "\n\n" + "WHEN TO USE True: recurring script-only pings where the script itself produces the exact message text (memory/disk/GPU watchdogs, threshold alerts, heartbeats, CI notifications, API pollers with a fixed output shape). " + "WHEN TO USE False (default): anything that needs reasoning — summarize a feed, draft a daily briefing, pick interesting items, rephrase data for a human, follow conditional logic based on content." + ), }, "context_from": { "type": "array", @@ -604,6 +654,7 @@ registry.register( context_from=args.get("context_from"), enabled_toolsets=args.get("enabled_toolsets"), workdir=args.get("workdir"), + no_agent=args.get("no_agent"), task_id=kw.get("task_id"), ))(), check_fn=check_cronjob_requirements, diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md index b35897e897..b47dae9378 100644 --- a/website/docs/guides/automate-with-cron.md +++ b/website/docs/guides/automate-with-cron.md @@ -14,6 +14,10 @@ For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/fe Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know. ::: +:::tip Don't need the LLM? Use no-agent mode. +For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. +::: + --- ## Pattern 1: Website Change Monitor diff --git a/website/docs/guides/cron-script-only.md b/website/docs/guides/cron-script-only.md new file mode 100644 index 0000000000..67ab178d7a --- /dev/null +++ b/website/docs/guides/cron-script-only.md @@ -0,0 +1,194 @@ +--- +sidebar_position: 13 +title: "Script-Only Cron Jobs (No LLM)" +description: "Classic watchdog cron jobs that skip the LLM entirely — a script runs on schedule and its stdout gets delivered to your messaging platform. Memory alerts, disk alerts, CI pings, periodic health checks." +--- + +# Script-Only Cron Jobs + +Sometimes you already know exactly what message you want to send. You don't need an agent to reason about it — you just need a script to run on a timer, and its output (if any) to land in Telegram / Discord / Slack / Signal. + +Hermes calls this **no-agent mode**. It's the cron system minus the LLM. + +``` + ┌──────────────────┐ ┌──────────────────┐ + │ scheduler tick │ every │ run script │ + │ (every N minutes)│ ──────▶ │ (bash or python) │ + └──────────────────┘ └──────────────────┘ + │ + │ stdout + ▼ + ┌──────────────────┐ + │ delivery router │ + │ (telegram/disc…) │ + └──────────────────┘ +``` + +- **No LLM call.** Zero tokens, zero agent loop, zero model spend. +- **Script is the job.** The script decides whether to alert. Emit output → message gets sent. Emit nothing → silent tick. +- **Bash or Python.** `.sh` / `.bash` files run under `/bin/bash`; any other extension runs under the current Python interpreter. Anything in `~/.hermes/scripts/` is accepted. +- **Same scheduler.** Lives in `cronjob` alongside LLM jobs — pausing, resuming, listing, logs, and delivery targeting all work the same way. + +## When to Use It + +Use no-agent mode for: + +- **Memory / disk / GPU watchdogs.** Run every 5 minutes, alert only when a threshold is breached. +- **CI hooks.** Deploy finished → post the commit SHA. Build failed → send the last 100 lines of the log. +- **Periodic metrics.** "Daily Stripe revenue at 9am" as a simple API call + pretty-print. +- **External event pollers.** Check an API, alert on state change. +- **Heartbeats.** Ping a dashboard every N minutes to prove the host is alive. + +Use a normal (LLM-driven) cron job when you need the agent to **decide** what to say — summarize a long document, pick interesting items from a feed, draft a human-friendly message. The no-agent path is for cases where the script's stdout already IS the message. + +## Create One from the CLI + +```bash +# 1. Write your script +cat > ~/.hermes/scripts/memory-watchdog.sh <<'EOF' +#!/usr/bin/env bash +# Alert when RAM usage is over 85%. Silent otherwise. +RAM_PCT=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}') +if [ "$RAM_PCT" -ge 85 ]; then + echo "⚠ RAM ${RAM_PCT}% on $(hostname)" +fi +# Empty stdout = silent run; no message sent. +EOF +chmod +x ~/.hermes/scripts/memory-watchdog.sh + +# 2. Schedule it +hermes cron create "every 5m" \ + --no-agent \ + --script memory-watchdog.sh \ + --deliver telegram \ + --name "memory-watchdog" + +# 3. Verify +hermes cron list +hermes cron run # fire it once to test +``` + +That's the whole thing. No prompt, no skill, no model. + +## Create One from Chat + +You can also ask the agent to set one up conversationally. The `cronjob` tool now accepts a `no_agent` parameter: + +> *"Ping me on Telegram if RAM is over 85%, every 5 minutes."* + +The agent will: + +1. Write the check script to `~/.hermes/scripts/` via `write_file`. +2. Call `cronjob(action='create', schedule='every 5m', script='memory-watchdog.sh', no_agent=true, deliver='telegram')`. + +This is the same scheduler the agent already uses for LLM-driven jobs; `no_agent=true` just picks the script-only code path. + +## How Script Output Maps to Delivery + +| Script behavior | Result | +|-----------------|--------| +| Exit 0, non-empty stdout | stdout is delivered verbatim | +| Exit 0, empty stdout | Silent tick — no delivery | +| Exit 0, stdout contains `{"wakeAgent": false}` on the last line | Silent tick (shared gate with LLM jobs) | +| Non-zero exit code | Error alert is delivered (so a broken watchdog doesn't fail silently) | +| Script timeout | Error alert is delivered | + +The "silent when empty" behavior is the key to the classic watchdog pattern: the script is free to run every minute, but the channel only sees a message when something actually needs attention. + +## Script Rules + +Scripts must live in `~/.hermes/scripts/`. This is enforced at both job-creation time and run time — absolute paths, `~/` expansion, and path-traversal patterns (`../`) are rejected. The same directory is shared with the pre-check script gate used by LLM jobs. + +Interpreter choice is by file extension: + +| Extension | Interpreter | +|-----------|-------------| +| `.sh`, `.bash` | `/bin/bash` | +| anything else | `sys.executable` (current Python) | + +We intentionally do NOT honour `#!/...` shebangs — keeping the interpreter set explicit and small reduces the surface the scheduler trusts. + +## Schedule Syntax + +Same as all other cron jobs: + +```bash +hermes cron create "every 5m" # interval +hermes cron create "every 2h" +hermes cron create "0 9 * * *" # standard cron: 9am daily +hermes cron create "30m" # one-shot: run once in 30 minutes +``` + +See the [cron feature reference](/docs/user-guide/features/cron) for the full syntax. + +## Delivery Targets + +`--deliver` accepts everything the gateway knows about. Some common shapes: + +```bash +--deliver telegram # platform home channel +--deliver telegram:-1001234567890 # specific chat +--deliver telegram:-1001234567890:17585 # specific Telegram forum topic +--deliver discord:#ops +--deliver slack:#engineering +--deliver signal:+15551234567 +--deliver local # just save to ~/.hermes/cron/output/ +``` + +No running gateway is required at script-run time for bot-token platforms (Telegram, Discord, Slack, Signal, SMS, WhatsApp) — the tool calls each platform's REST endpoint directly using the credentials already in `~/.hermes/.env` / `~/.hermes/config.yaml`. + +## Editing and Lifecycle + +```bash +hermes cron list # see all jobs +hermes cron pause # stop firing, keep definition +hermes cron resume +hermes cron edit --schedule "every 10m" # adjust cadence +hermes cron edit --agent # flip to LLM mode +hermes cron edit --no-agent --script … # flip back +hermes cron remove # delete it +``` + +Everything that works on LLM jobs (pause, resume, manual trigger, delivery target changes) works on no-agent jobs too. + +## Worked Example: Disk Space Alert + +```bash +cat > ~/.hermes/scripts/disk-alert.sh <<'EOF' +#!/usr/bin/env bash +# Alert when / or /home is over 90% full. +THRESHOLD=90 +df -h / /home 2>/dev/null | awk -v t="$THRESHOLD" ' + NR > 1 && $5+0 >= t { + printf "⚠ Disk %s full on %s\n", $5, $6 + } +' +EOF +chmod +x ~/.hermes/scripts/disk-alert.sh + +hermes cron create "*/15 * * * *" \ + --no-agent \ + --script disk-alert.sh \ + --deliver telegram \ + --name "disk-alert" +``` + +Silent when both filesystems are under 90%; fires exactly one line per over-threshold filesystem when one fills up. + +## Comparison with Other Patterns + +| Approach | What runs | When to use | +|----------|-----------|-------------| +| `hermes send` (one-shot) | Any shell command piping into it | Ad-hoc delivery or as the action of an external scheduler (systemd, launchd) | +| `cronjob --no-agent` (this page) | Your script on Hermes' schedule | Recurring watchdogs / alerts / metrics that don't need reasoning | +| `cronjob` (default, LLM) | Agent with optional pre-check script | When the message content requires reasoning over data | +| OS cron + `hermes send` | Your script on the OS schedule | When Hermes might be unhealthy (the thing you're monitoring) | + +For critical system-health watchdogs that must fire *even when the gateway is down*, keep using OS-level cron + a plain `curl` or `hermes send` call — those run as independent OS processes and don't depend on Hermes being up. The in-gateway scheduler is the right choice when the thing being monitored is external. + +## Related + +- [Automate Anything with Cron](/docs/guides/automate-with-cron) — LLM-driven cron patterns. +- [Scheduled Tasks (Cron) reference](/docs/user-guide/features/cron) — full schedule syntax, lifecycle, delivery routing. +- [Pipe Script Output with `hermes send`](/docs/guides/pipe-script-output) — the one-shot counterpart for ad-hoc scripts. +- [Gateway Internals](/docs/developer-guide/gateway-internals) — delivery-router internals. diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index e74d800460..cd6b4652ba 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -286,6 +286,30 @@ cron: Or set the `HERMES_CRON_SCRIPT_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 120s default. +## No-agent mode (script-only jobs) + +For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely: + +```bash +hermes cron create "every 5m" \ + --no-agent \ + --script memory-watchdog.sh \ + --deliver telegram \ + --name "memory-watchdog" +``` + +Semantics: + +- Script stdout (trimmed) → delivered verbatim as the message. +- **Empty stdout → silent tick**, no delivery. This is the watchdog pattern: "only say something when something is wrong". +- Non-zero exit or timeout → an error alert is delivered, so a broken watchdog can't fail silently. +- `{"wakeAgent": false}` on the last line → silent tick (same gate LLM jobs use). +- No tokens, no model, no provider fallback — the job never touches the inference layer. + +`.sh` / `.bash` files run under `/bin/bash`; anything else under the current Python interpreter (`sys.executable`). Scripts must live in `~/.hermes/scripts/` (same sandboxing rule as the pre-run script gate). + +See the [Script-Only Cron Jobs guide](/docs/guides/cron-script-only) for worked examples. + ## Provider recovery Cron jobs inherit your configured fallback providers and credential pool rotation. If the primary API key is rate-limited or the provider returns an error, the cron agent can: