feat: cron agents can suppress delivery with [SILENT] response (#1833)
feat: cron agents can suppress delivery with [SILENT] response
This commit is contained in:
commit
b8076bb0bd
4 changed files with 124 additions and 4 deletions
2
cli.py
2
cli.py
|
|
@ -3560,7 +3560,7 @@ class HermesCLI:
|
|||
elif canonical == "reload-mcp":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
elif _base_word == "browser":
|
||||
elif canonical == "browser":
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
"Tools & Skills", cli_only=True),
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue