Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ def create_job(
provider: Optional[str] = None,
base_url: Optional[str] = None,
script: Optional[str] = None,
script_skip_if_empty: bool = False,
) -> Dict[str, Any]:
"""
Create a new cron job.
Expand All @@ -397,6 +398,9 @@ def create_job(
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_skip_if_empty: If True and the script exits zero with empty stdout,
skip the LLM invocation entirely (no delivery). Script errors still
cause a failure. Allows "only act when something changed" patterns.

Returns:
The created job dict
Expand Down Expand Up @@ -427,6 +431,7 @@ def create_job(
normalized_base_url = normalized_base_url or None
normalized_script = str(script).strip() if isinstance(script, str) else None
normalized_script = normalized_script or None
normalized_script_skip_if_empty = bool(script_skip_if_empty)

label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job"
job = {
Expand All @@ -439,6 +444,7 @@ def create_job(
"provider": normalized_provider,
"base_url": normalized_base_url,
"script": normalized_script,
"script_skip_if_empty": normalized_script_skip_if_empty,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
"repeat": {
Expand Down
88 changes: 58 additions & 30 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,36 +487,36 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
return False, f"Script execution failed: {exc}"


def _build_job_prompt(job: dict) -> str:
def _build_job_prompt(job: dict, script_output: Optional[str] = None) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
prompt = job.get("prompt", "")
skills = job.get("skills")

# Run data-collection script if configured, inject output as context.
script_path = job.get("script")
if script_path:
success, script_output = _run_job_script(script_path)
if success:
if script_output:
prompt = (
"## Script Output\n"
"The following data was collected by a pre-run script. "
"Use it as context for your analysis.\n\n"
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
# If script_output is None, the caller didn't pre-run the script β€”
# run it ourselves so _build_job_prompt remains testable as a unit.
if script_output is None and job.get("script"):
ok, script_output = _run_job_script(job["script"])
if not ok:
script_output = f"[Script error]: {script_output}"
elif not script_output:
# Don't inject "no output" notice when skip_if_empty is set β€”
# run_job will handle the skip itself.
if not job.get("script_skip_if_empty"):
script_output = "[Script ran successfully but produced no output.]"
else:
prompt = (
"[Script ran successfully but produced no output.]\n\n"
f"{prompt}"
)
else:
prompt = (
"## Script Error\n"
"The data-collection script failed. Report this to the user.\n\n"
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
script_output = None

# Inject script output / error into prompt if a script was run
if script_output is not None:
is_error = script_output.startswith("[Script error]")
header = "## Script Error" if is_error else "## Script Output"
prompt = (
f"{header}\n"
"The following data was collected by a pre-run script. "
"Use it as context for your analysis.\n\n"
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
skills = job.get("skills")

# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
Expand Down Expand Up @@ -580,12 +580,12 @@ def _build_job_prompt(job: dict) -> str:
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
Execute a single cron job.

Returns:
Tuple of (success, full_output_doc, final_response, error_message)
"""
from run_agent import AIAgent

# Initialize SQLite session store so cron job messages are persisted
# and discoverable via session_search (same pattern as gateway/run.py).
_session_db = None
Expand All @@ -594,13 +594,41 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
_session_db = SessionDB()
except Exception as e:
logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)

job_id = job["id"]
job_name = job["name"]
prompt = _build_job_prompt(job)
origin = _resolve_origin(job)
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"

# Script: run before the LLM to collect context.
# If script_skip_if_empty is set and script returns empty output,
# skip the LLM invocation entirely.
script_path = job.get("script")
script_skip_if_empty = job.get("script_skip_if_empty", False)
script_output = None
if script_path:
logger.info("Running script: %s", script_path)
ok, script_output = _run_job_script(script_path)
if not ok:
script_output = f"[Script error]: {script_output}"
logger.warning("Job '%s': script failed β€” %s", job_name, script_output)
elif not script_output.strip() and script_skip_if_empty:
output = f"""# Cron Job: {job_name}

**Job ID:** {job_id}
**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}
**Schedule:** {job.get('schedule_display', 'N/A')}

Script returned no output (script_skip_if_empty=true) β€” skipping LLM silently.
"""
logger.info("Job '%s': script empty + skip_if_empty β€” skipping LLM", job_name)
return True, output, "", None
elif not script_output.strip():
# Script succeeded but produced no output β€” note it for the LLM
script_output = "[Script ran successfully but produced no output.]"
logger.info("Script output: %s", (script_output or "(empty)")[:100])

prompt = _build_job_prompt(job, script_output=script_output)
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
logger.info("Prompt: %s", prompt[:100])

Expand Down
8 changes: 5 additions & 3 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,22 @@
"teknium@nousresearch.com": "teknium1",
"127238744+teknium1@users.noreply.github.com": "teknium1",
# contributors (from noreply pattern)
# manual overrides
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
"145567217+Aum08Desai@users.noreply.github.com": "Aum08Desai",
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
"112503481+caentzminger@users.noreply.github.com": "caentzminger",
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
"126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
"137614867+cutepawss@users.noreply.github.com": "cutepawss",
"96793918+memosr@users.noreply.github.com": "memosr",
"131039422+SHL0MS@users.noreply.github.com": "SHL0MS",
"77628552+raulvidis@users.noreply.github.com": "raulvidis",
"145567217+Aum08Desai@users.noreply.github.com": "Aum08Desai",
"256820943+kshitij-eliza@users.noreply.github.com": "kshitij-eliza",
"44278268+shitcoinsherpa@users.noreply.github.com": "shitcoinsherpa",
"104278804+Sertug17@users.noreply.github.com": "Sertug17",
"112503481+caentzminger@users.noreply.github.com": "caentzminger",
"258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
Expand Down Expand Up @@ -166,6 +167,7 @@
"hmbown@gmail.com": "Hmbown",
"iacobs@m0n5t3r.info": "m0n5t3r",
"jiayuw794@gmail.com": "JiayuuWang",
"jneeee@outlook.com": "jneeee",
"jonny@nousresearch.com": "jquesnelle",
"juan.ovalle@mistral.ai": "jjovalle99",
"julien.talbot@ergonomia.re": "Julientalbot",
Expand Down
116 changes: 116 additions & 0 deletions tests/cron/test_cron_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,119 @@ def test_env_vars_cleaned_on_early_error(self, cron_env, monkeypatch):
assert os.environ.get("HERMES_SESSION_PLATFORM") is None
assert os.environ.get("HERMES_SESSION_CHAT_ID") is None
assert os.environ.get("HERMES_SESSION_CHAT_NAME") is None


class TestScriptSkipIfEmpty:
"""Tests for script_skip_if_empty field and behavior."""

def test_create_job_with_script_skip_if_empty(self, cron_env):
from cron.jobs import create_job, get_job

job = create_job(
prompt="Analyze the data",
schedule="every 30m",
script="check_changes.py",
script_skip_if_empty=True,
)
assert job["script_skip_if_empty"] is True

loaded = get_job(job["id"])
assert loaded["script_skip_if_empty"] is True

def test_create_job_default_script_skip_if_empty_false(self, cron_env):
from cron.jobs import create_job

job = create_job(prompt="Hello", schedule="every 1h")
assert job.get("script_skip_if_empty") is False

def test_update_script_skip_if_empty(self, cron_env):
from cron.jobs import create_job, update_job

job = create_job(prompt="Hello", schedule="every 1h")
assert job.get("script_skip_if_empty") is False

updated = update_job(job["id"], {"script_skip_if_empty": True})
assert updated["script_skip_if_empty"] is True

def test_update_script_skip_if_empty_false(self, cron_env):
from cron.jobs import create_job, update_job

job = create_job(
prompt="Hello", schedule="every 1h", script="check.py", script_skip_if_empty=True
)
assert job["script_skip_if_empty"] is True

updated = update_job(job["id"], {"script_skip_if_empty": False})
assert updated["script_skip_if_empty"] is False

def test_build_job_prompt_with_skip_if_empty_false_no_script(self, cron_env):
"""When script_skip_if_empty=false (default) and script returns empty,
_build_job_prompt still injects the notice and calls LLM."""
from cron.scheduler import _build_job_prompt

script = cron_env / "scripts" / "noop.py"
script.write_text("# nothing\n")

job = {
"prompt": "Check status.",
"script": str(script),
"script_skip_if_empty": False,
}
prompt = _build_job_prompt(job)
assert "no output" in prompt.lower()
assert "Check status." in prompt

def test_build_job_prompt_with_skip_if_empty_true_no_script(self, cron_env):
"""When script_skip_if_empty=true and script returns empty output,
_build_job_prompt does NOT inject "no output" β€” it lets run_job handle the skip."""
from cron.scheduler import _build_job_prompt

script = cron_env / "scripts" / "noop.py"
script.write_text("# nothing\n")

job = {
"prompt": "Check status.",
"script": str(script),
"script_skip_if_empty": True,
}
prompt = _build_job_prompt(job)
# The "no output" notice should NOT appear since run_job will skip LLM
assert "no output" not in prompt.lower()
assert "Check status." in prompt


class TestCronjobToolScriptSkipIfEmpty:
"""Test script_skip_if_empty via the cronjob tool API."""

def test_create_with_script_skip_if_empty(self, cron_env, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
from tools.cronjob_tools import cronjob

result = json.loads(cronjob(
action="create",
schedule="every 1h",
prompt="Monitor things",
script="check.py",
script_skip_if_empty=True,
))
assert result["success"] is True
assert result["job"]["script_skip_if_empty"] is True

def test_update_script_skip_if_empty(self, cron_env, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
from tools.cronjob_tools import cronjob

create_result = json.loads(cronjob(
action="create",
schedule="every 1h",
prompt="Monitor things",
))
job_id = create_result["job_id"]

update_result = json.loads(cronjob(
action="update",
job_id=job_id,
script_skip_if_empty=True,
))
assert update_result["success"] is True
assert update_result["job"]["script_skip_if_empty"] is True
6 changes: 6 additions & 0 deletions tools/cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
}
if job.get("script"):
result["script"] = job["script"]
if job.get("script_skip_if_empty"):
result["script_skip_if_empty"] = True
return result


Expand All @@ -234,6 +236,7 @@ def cronjob(
base_url: Optional[str] = None,
reason: Optional[str] = None,
script: Optional[str] = None,
script_skip_if_empty: bool = False,
task_id: str = None,
) -> str:
"""Unified cron job management tool."""
Expand Down Expand Up @@ -271,6 +274,7 @@ def cronjob(
provider=_normalize_optional_job_value(provider),
base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True),
script=_normalize_optional_job_value(script),
script_skip_if_empty=script_skip_if_empty,
)
return json.dumps(
{
Expand Down Expand Up @@ -360,6 +364,8 @@ def cronjob(
if script_error:
return tool_error(script_error, success=False)
updates["script"] = _normalize_optional_job_value(script) if script else None
if script_skip_if_empty is not None:
updates["script_skip_if_empty"] = bool(script_skip_if_empty)
if repeat is not None:
# Normalize: treat 0 or negative as None (infinite)
normalized_repeat = None if repeat <= 0 else repeat
Expand Down
31 changes: 23 additions & 8 deletions website/docs/developer-guide/cron-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ Jobs are stored in `~/.hermes/cron/jobs.json` with atomic write semantics (write
"created_at": "2025-01-01T00:00:00Z",
"model": null,
"provider": null,
"script": null
"script": null,
"script_skip_if_empty": false
}
```

Expand Down Expand Up @@ -90,12 +91,13 @@ tick()
4. For each due job:
a. Set state to "running"
b. Create fresh AIAgent session (no conversation history)
c. Load attached skills in order (injected as user messages)
d. Run the job prompt through the agent
e. Deliver the response to the configured target
f. Update run_count, compute next_run
g. If repeat count exhausted β†’ state = "completed"
h. Otherwise β†’ state = "scheduled"
c. Run script (if configured) β€” skip LLM if script exits zero with empty output and script_skip_if_empty is true
d. Load attached skills in order (injected as user messages)
e. Run the job prompt through the agent
f. Deliver the response to the configured target
g. Update run_count, compute next_run
h. If repeat count exhausted β†’ state = "completed"
i. Otherwise β†’ state = "scheduled"
5. Write updated jobs back to jobs.json
6. Release scheduler lock
```
Expand Down Expand Up @@ -141,7 +143,20 @@ import requests, json
# Print summary to stdout β€” agent analyzes and reports
```

The script timeout defaults to 120 seconds. `_get_script_timeout()` resolves the limit through a three-layer chain:
When `script_skip_if_empty` is `true`, a script that exits zero with empty stdout causes the job to skip the LLM invocation entirely (silent skip, no delivery). This allows "only act when something changed" patterns without needing a separate precheck mechanism.

```json
{
"id": "a1b2c3d4e5f6",
"name": "GitHub commit summary",
"prompt": "Summarize the new commits below.",
"schedule": { "kind": "cron", "expr": "0 9 * * *" },
"script": "check_github.py",
"script_skip_if_empty": true
}
```

Script timeout defaults to 120 seconds. `_get_script_timeout()` resolves the limit through a three-layer chain:

1. **Module-level override** β€” `_SCRIPT_TIMEOUT` (for tests/monkeypatching). Only used when it differs from the default.
2. **Environment variable** β€” `HERMES_CRON_SCRIPT_TIMEOUT`
Expand Down
Loading
Loading