feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709)

* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)

Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.

## What

  hermes cron create "every 5m" \
    --no-agent \
    --script memory-watchdog.sh \
    --deliver telegram \
    --name memory-watchdog

Agent tool:

  cronjob(action='create',
          schedule='every 5m',
          script='memory-watchdog.sh',
          no_agent=True,
          deliver='telegram')

Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout          → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate  → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
                          (broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied

## Implementation

cron/jobs.py
  * create_job gains no_agent: bool = False
  * prompt becomes Optional (no_agent jobs don't need one)
  * Validation: no_agent=True requires a script at create time
  * Field roundtrips via load_jobs / save_jobs / update_job

cron/scheduler.py
  * run_job: new short-circuit branch at the top that runs the script,
    wraps its output into the (success, doc, final_response, error)
    tuple downstream delivery already expects, and returns before any
    AIAgent import or construction
  * _run_job_script: picks interpreter by extension — .sh/.bash run
    under /bin/bash, anything else under sys.executable (Python).
    Shell support unlocks the bash-watchdog pattern without wrapping
    scripts in Python. Extension is explicit; we deliberately do NOT
    trust the file's own shebang. Path-containment guard (scripts dir)
    unchanged.

tools/cronjob_tools.py
  * Schema: new no_agent boolean property with clear trigger guidance
  * cronjob() accepts no_agent and validates mode-specific shape:
    - no_agent=True requires script; prompt/skills optional
    - no_agent=False keeps the existing 'prompt or skill required' rule
  * update path rejects flipping no_agent=True on a job without a script
  * _format_job surfaces no_agent in list output
  * Handler lambda forwards no_agent from tool args

hermes_cli/main.py, hermes_cli/cron.py
  * 'hermes cron create --no-agent' and edit's --no-agent / --agent
    pair for toggling at CLI parity with the agent tool
  * Existing --script help text updated to describe both modes
  * List / create / edit output now shows 'Mode: no-agent (...)' when set

## Tests

tests/cron/test_cron_no_agent.py — 18 tests covering:
  * create_job: no_agent shape, validation, field persistence
  * update_job: flag roundtrip across reload
  * cronjob tool: schema validation, update toggling, mode-specific
    requirements, prompt-relaxation rule
  * run_job short-circuit:
    - success path delivers stdout verbatim
    - empty stdout → SILENT_MARKER (no delivery downstream)
    - wakeAgent=false gate → silent
    - script failure → error alert
    - run_job does NOT import AIAgent (verified via mock)
  * _run_job_script:
    - .sh executes via bash (no shebang required)
    - .bash executes via bash
    - .py still runs via sys.executable (regression)
    - path-traversal still blocked (security regression)

All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.

## Docs

website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.

website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.

website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.

## Compatibility

- Existing jobs: unchanged. no_agent defaults to False, existing code
  paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
  via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.

* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero

The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.

Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:

    assert 'run_agent' not in sys.modules  # before
    run_job(no_agent_job)
    assert 'run_agent' not in sys.modules  # after

The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.

* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule

Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:

1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
   'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
   examples on both sides (watchdogs/metrics/pollers → True;
   summarize/draft/pick/rephrase → False)

Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.

No behavior change — schema text only.
This commit is contained in:
Teknium 2026-05-04 12:31:01 -07:00 committed by GitHub
parent d35efb9898
commit 3db6b9cc87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 823 additions and 17 deletions

View file

@ -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),

View file

@ -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,6 +850,118 @@ 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
@ -841,9 +973,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
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
# the whole agent run. We pass the result into _build_job_prompt so

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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 <job_id> # 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 <job_id> # stop firing, keep definition
hermes cron resume <job_id>
hermes cron edit <job_id> --schedule "every 10m" # adjust cadence
hermes cron edit <job_id> --agent # flip to LLM mode
hermes cron edit <job_id> --no-agent --script … # flip back
hermes cron remove <job_id> # 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.

View file

@ -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: