diff --git a/cron/scheduler.py b/cron/scheduler.py index a3636883f..2060bf2fb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -37,6 +37,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import get_due_jobs, mark_job_run, save_job_output +# Sentinel: when a cron agent has nothing new to report, it can start its +# response with this marker to suppress delivery. Output is still saved +# locally for audit. +SILENT_MARKER = "[SILENT]" + # Resolve Hermes home directory (respects HERMES_HOME override) _hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) @@ -180,6 +185,17 @@ def _build_job_prompt(job: dict) -> str: """Build the effective prompt for a cron job, optionally loading one or more skills first.""" prompt = job.get("prompt", "") skills = job.get("skills") + + # Always prepend [SILENT] guidance so the cron agent can suppress + # delivery when it has nothing new or noteworthy to report. + silent_hint = ( + "[SYSTEM: If you have nothing new or noteworthy to report, respond " + "with exactly \"[SILENT]\" (optionally followed by a brief internal " + "note). This suppresses delivery to the user while still saving " + "output locally. Only use [SILENT] when there are genuinely no " + "changes worth reporting.]\n\n" + ) + prompt = silent_hint + prompt if skills is None: legacy = job.get("skill") skills = [legacy] if legacy else [] @@ -480,9 +496,16 @@ def tick(verbose: bool = True) -> int: if verbose: logger.info("Output saved to: %s", output_file) - # Deliver the final response to the origin/target chat + # Deliver the final response to the origin/target chat. + # If the agent responded with [SILENT], skip delivery (but + # output is already saved above). Failed jobs always deliver. deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" - if deliver_content: + should_deliver = bool(deliver_content) + if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER): + logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) + should_deliver = False + + if should_deliver: try: _deliver_result(job, deliver_content) except Exception as de: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index ad256714a..6c3926337 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch, MagicMock import pytest -from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job +from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job, SILENT_MARKER class TestResolveOrigin: @@ -449,3 +449,97 @@ class TestRunJobSkillBacked: assert "Instructions for blogwatcher." in prompt_arg assert "Instructions for find-nearby." in prompt_arg assert "Combine the results." in prompt_arg + + +class TestSilentDelivery: + """Verify that [SILENT] responses suppress delivery while still saving output.""" + + def _make_job(self): + return { + "id": "monitor-job", + "name": "monitor", + "deliver": "origin", + "origin": {"platform": "telegram", "chat_id": "123"}, + } + + def test_normal_response_delivers(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_called_once() + + def test_silent_response_suppresses_delivery(self, caplog): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + with caplog.at_level(logging.INFO, logger="cron.scheduler"): + tick(verbose=False) + deliver_mock.assert_not_called() + assert any(SILENT_MARKER in r.message for r in caplog.records) + + def test_silent_with_note_suppresses_delivery(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT] No changes detected", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_not_called() + + def test_silent_is_case_insensitive(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_not_called() + + def test_failed_job_always_delivers(self): + """Failed jobs deliver regardless of [SILENT] in output.""" + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(False, "# output", "", "some error")), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_called_once() + + def test_output_saved_even_when_delivery_suppressed(self): + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# full output", "[SILENT]", None)), \ + patch("cron.scheduler.save_job_output") as save_mock, \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + save_mock.return_value = "/tmp/out.md" + from cron.scheduler import tick + tick(verbose=False) + save_mock.assert_called_once_with("monitor-job", "# full output") + deliver_mock.assert_not_called() + + +class TestBuildJobPromptSilentHint: + """Verify _build_job_prompt always injects [SILENT] guidance.""" + + def test_hint_always_present(self): + from cron.scheduler import _build_job_prompt + job = {"prompt": "Check for updates"} + result = _build_job_prompt(job) + assert "[SILENT]" in result + assert "Check for updates" in result + + def test_hint_present_even_without_prompt(self): + from cron.scheduler import _build_job_prompt + job = {"prompt": ""} + result = _build_job_prompt(job) + assert "[SILENT]" in result