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:
parent
d35efb9898
commit
3db6b9cc87
9 changed files with 823 additions and 17 deletions
37
cron/jobs.py
37
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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
332
tests/cron/test_cron_no_agent.py
Normal file
332
tests/cron/test_cron_no_agent.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
194
website/docs/guides/cron-script-only.md
Normal file
194
website/docs/guides/cron-script-only.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue