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
4 changes: 4 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ def create_job(
provider: Optional[str] = None,
base_url: Optional[str] = None,
script: Optional[str] = None,
script_skip_if_empty: bool = False,
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
Expand All @@ -439,6 +440,8 @@ 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: When True, a successful script run with empty stdout
skips the LLM turn entirely and suppresses delivery.
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.
Expand Down Expand Up @@ -504,6 +507,7 @@ def create_job(
"provider": normalized_provider,
"base_url": normalized_base_url,
"script": normalized_script,
"script_skip_if_empty": bool(script_skip_if_empty),
"context_from": context_from,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
Expand Down
19 changes: 19 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,24 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"Script gate returned `wakeAgent=false` — agent skipped.\n"
)
return True, silent_doc, SILENT_MARKER, None
# Opt-in skip: when ``script_skip_if_empty`` is set, treat a
# successful run with empty stdout the same as ``wakeAgent=false``.
if (
_ran_ok
and job.get("script_skip_if_empty")
and not _script_output.strip()
):
logger.info(
"Job '%s' (ID: %s): script_skip_if_empty matched empty stdout, skipping agent run",
job_name, job_id,
)
silent_doc = (
f"# Cron Job: {job_name}\n\n"
f"**Job ID:** {job_id}\n"
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
"Pre-run script produced no output and `script_skip_if_empty` is enabled — agent skipped.\n"
)
return True, silent_doc, SILENT_MARKER, None

prompt = _build_job_prompt(job, prerun_script=prerun_script)
origin = _resolve_origin(job)
Expand Down Expand Up @@ -861,6 +879,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
os.environ["TERMINAL_CWD"] = _job_workdir
logger.info("Job '%s': using workdir %s", job_id, _job_workdir)


try:
# Re-read .env and config.yaml fresh every run so provider/key
# changes take effect without a gateway restart.
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@
"shamork@outlook.com": "shamork",
# April 2026 Discord Copilot /model salvage (#15030)
"cshong2017@outlook.com": "Nicecsh",
"sky.cool.ezreal@gmail.com": "cola-runner",
# no-github-match — keep as display names
"clio-agent@sisyphuslabs.ai": "Sisyphus",
"marco@rutimka.de": "Marco Rutsch",
Expand Down
64 changes: 64 additions & 0 deletions tests/cron/test_cron_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ def test_create_job_empty_script_normalized_to_none(self, cron_env):
job = create_job(prompt="Hello", schedule="every 1h", script=" ")
assert job.get("script") is None

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

job = create_job(
prompt="Hello",
schedule="every 1h",
script="noop.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_update_job_add_script(self, cron_env):
from cron.jobs import create_job, update_job

Expand Down Expand Up @@ -244,6 +258,20 @@ def test_create_with_script(self, cron_env, monkeypatch):
assert result["success"] is True
assert result["job"]["script"] == "monitor.py"

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="monitor.py",
script_skip_if_empty=True,
))
assert result["success"] is True
assert result["job"]["script_skip_if_empty"] is True

def test_update_script(self, cron_env, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
from tools.cronjob_tools import cronjob
Expand All @@ -263,6 +291,26 @@ def test_update_script(self, cron_env, monkeypatch):
assert update_result["success"] is True
assert update_result["job"]["script"] == "new_script.py"

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",
script="monitor.py",
))
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

def test_clear_script(self, cron_env, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
from tools.cronjob_tools import cronjob
Expand Down Expand Up @@ -299,6 +347,22 @@ def test_list_shows_script(self, cron_env, monkeypatch):
assert len(list_result["jobs"]) == 1
assert list_result["jobs"][0]["script"] == "data_collector.py"

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

cronjob(
action="create",
schedule="every 1h",
prompt="Monitor things",
script="data_collector.py",
script_skip_if_empty=True,
)

list_result = json.loads(cronjob(action="list"))
assert list_result["success"] is True
assert list_result["jobs"][0]["script_skip_if_empty"] is True


class TestScriptPathContainment:
"""Regression tests for path containment bypass in _run_job_script().
Expand Down
62 changes: 62 additions & 0 deletions tests/cron/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,68 @@ def test_bad_prefill_messages_is_logged(self, caplog, tmp_path):
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"


class TestRunJobScriptSkipIfEmpty:
def test_run_job_skips_llm_when_script_output_is_empty(self, tmp_path):
from cron.scheduler import SILENT_MARKER

job = {
"id": "skip-empty-job",
"name": "skip empty",
"prompt": "Check for changes.",
"script": "empty.py",
"script_skip_if_empty": True,
}
fake_db = MagicMock()

with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("cron.scheduler._run_job_script", return_value=(True, "")), \
patch("run_agent.AIAgent") as mock_agent_cls:
success, output, final_response, error = run_job(job)

assert success is True
assert error is None
assert final_response == SILENT_MARKER
assert "script_skip_if_empty" in output
mock_agent_cls.assert_not_called()

def test_run_job_runs_when_script_output_is_non_empty(self, tmp_path):
job = {
"id": "skip-empty-job-2",
"name": "skip empty 2",
"prompt": "Check for changes.",
"script": "non_empty.py",
"script_skip_if_empty": True,
}
fake_db = MagicMock()
fake_runtime = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "http://127.0.0.1:4000/v1",
"api_key": "***",
}

with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("cron.scheduler._run_job_script", return_value=(True, "actual output")), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime), \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ran"}
mock_agent_cls.return_value = mock_agent

success, _output, final_response, error = run_job(job)

assert success is True
assert error is None
assert final_response == "ran"
mock_agent_cls.assert_called_once()



class TestRunJobSkillBacked:
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
job = {
Expand Down
11 changes: 11 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
if job.get("enabled_toolsets"):
result["enabled_toolsets"] = job["enabled_toolsets"]
if job.get("workdir"):
Expand All @@ -238,6 +240,7 @@ def cronjob(
base_url: Optional[str] = None,
reason: Optional[str] = None,
script: Optional[str] = None,
script_skip_if_empty: Optional[bool] = None,
context_from: Optional[Union[str, List[str]]] = None,
enabled_toolsets: Optional[List[str]] = None,
workdir: Optional[str] = None,
Expand Down Expand Up @@ -290,6 +293,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=bool(script_skip_if_empty),
context_from=context_from,
enabled_toolsets=enabled_toolsets or None,
workdir=_normalize_optional_job_value(workdir),
Expand Down Expand Up @@ -382,6 +386,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 context_from is not None:
# Empty string / empty list clears the field; otherwise validate
# each referenced job exists before storing. Normalized to a list
Expand Down Expand Up @@ -505,6 +511,10 @@ def cronjob(
"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."
},
"script_skip_if_empty": {
"type": "boolean",
"description": "Optional. When true, if the pre-run script succeeds but produces empty stdout, skip the LLM run entirely and suppress delivery. On update, pass false to disable."
},
"context_from": {
"type": "array",
"items": {"type": "string"},
Expand Down Expand Up @@ -571,6 +581,7 @@ def check_cronjob_requirements() -> bool:
base_url=args.get("base_url"),
reason=args.get("reason"),
script=args.get("script"),
script_skip_if_empty=args.get("script_skip_if_empty"),
context_from=args.get("context_from"),
enabled_toolsets=args.get("enabled_toolsets"),
workdir=args.get("workdir"),
Expand Down
Loading