From e41934ec4d0e0b903b91b0a106c0f2b9538a9679 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:20:19 -0600 Subject: [PATCH] feat(cron): add opt-in session history delivery --- cli.py | 14 ++- cron/jobs.py | 4 + cron/scheduler.py | 38 ++++++- gateway/platforms/api_server.py | 4 +- hermes_cli/cron.py | 54 +++++++++ hermes_cli/main.py | 10 ++ tests/cron/test_jobs.py | 15 +++ tests/cron/test_scheduler.py | 105 +++++++++++++++++- tests/gateway/test_api_server_jobs.py | 20 ++++ tests/hermes_cli/test_cron.py | 58 ++++++++++ tools/cronjob_tools.py | 11 ++ .../docs/developer-guide/cron-internals.md | 4 +- website/docs/user-guide/features/cron.md | 19 ++++ 13 files changed, 351 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 9635a6799e4f..e4abde99d845 100644 --- a/cli.py +++ b/cli.py @@ -4503,6 +4503,7 @@ def _parse_flags(tokens): opts = { "name": None, "deliver": None, + "into_history": None, "repeat": None, "skills": [], "add_skills": [], @@ -4522,6 +4523,12 @@ def _parse_flags(tokens): elif token == "--deliver" and i + 1 < len(tokens): opts["deliver"] = tokens[i + 1] i += 2 + elif token == "--into-history": + opts["into_history"] = True + i += 1 + elif token == "--no-into-history": + opts["into_history"] = False + i += 1 elif token == "--repeat" and i + 1 < len(tokens): try: opts["repeat"] = int(tokens[i + 1]) @@ -4565,7 +4572,7 @@ def _parse_flags(tokens): print() print(" Commands:") print(" /cron list") - print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]') + print(' /cron add "every 2h" "Check server status" [--skill blogwatcher] [--into-history]') print(' /cron edit --schedule "every 4h" --prompt "New task"') print(" /cron edit --skill blogwatcher --skill find-nearby") print(" /cron edit --remove-skill blogwatcher") @@ -4615,6 +4622,7 @@ def _parse_flags(tokens): print(f" State: {job.get('state', '?')}") print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})") print(f" Next run: {job.get('next_run_at', 'N/A')}") + print(f" History: {'session' if job.get('into_history') else 'isolated'}") if job.get("skills"): print(f" Skills: {', '.join(job['skills'])}") print(f" Prompt: {job.get('prompt_preview', '')}") @@ -4640,12 +4648,14 @@ def _parse_flags(tokens): prompt=prompt or None, name=opts["name"], deliver=opts["deliver"], + into_history=opts["into_history"], repeat=opts["repeat"], skills=skills or None, ) if result.get("success"): print(f"(^_^)b Created job: {result['job_id']}") print(f" Schedule: {result['schedule']}") + print(f" History: {'session' if result.get('into_history') else 'isolated'}") if result.get("skills"): print(f" Skills: {', '.join(result['skills'])}") print(f" Next run: {result['next_run_at']}") @@ -4686,6 +4696,7 @@ def _parse_flags(tokens): prompt=opts["prompt"], name=opts["name"], deliver=opts["deliver"], + into_history=opts["into_history"], repeat=opts["repeat"], skills=final_skills, ) @@ -4693,6 +4704,7 @@ def _parse_flags(tokens): job = result["job"] print(f"(^_^)b Updated job: {job['job_id']}") print(f" Schedule: {job['schedule']}") + print(f" History: {'session' if job.get('into_history') else 'isolated'}") if job.get("skills"): print(f" Skills: {', '.join(job['skills'])}") else: diff --git a/cron/jobs.py b/cron/jobs.py index 4096d1fd81fb..a07460fcefb9 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -369,6 +369,7 @@ def create_job( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, + into_history: bool = False, origin: Optional[Dict[str, Any]] = None, skill: Optional[str] = None, skills: Optional[List[str]] = None, @@ -386,6 +387,8 @@ def create_job( name: Optional friendly name repeat: How many times to run (None = forever, 1 = once) deliver: Where to deliver output ("origin", "local", "telegram", etc.) + into_history: When true, mirror delivered output into the target gateway + session history so future turns can see it origin: Source info where job was created (for "origin" delivery) skill: Optional legacy single skill name to load before running the prompt skills: Optional ordered list of skills to load before running the prompt @@ -454,6 +457,7 @@ def create_job( "last_error": None, # Delivery configuration "deliver": deliver, + "into_history": bool(into_history), "origin": origin, # Tracks where job was created for "origin" delivery } diff --git a/cron/scheduler.py b/cron/scheduler.py index 23de3ffcc755..28e01e9d3f83 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -268,13 +268,20 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option except Exception: pass + into_history = bool(job.get("into_history", False)) + if wrap_response: task_name = job.get("name", job["id"]) + footer = ( + "Note: This scheduled update has been added to the conversation history for future replies." + if into_history + else "Note: The agent cannot see this message, and therefore cannot respond to it." + ) delivery_content = ( f"Cronjob Response: {task_name}\n" f"-------------\n\n" f"{content}\n\n" - f"Note: The agent cannot see this message, and therefore cannot respond to it." + f"{footer}" ) else: delivery_content = content @@ -311,6 +318,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option _send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job) if adapter_ok: + if into_history: + try: + from gateway.mirror import mirror_to_session + mirror_to_session( + platform_name, + chat_id, + cleaned_delivery_content, + source_label=f"cron:{job.get('name', job.get('id', '?'))}", + thread_id=thread_id, + ) + except Exception: + pass logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id) return None except Exception as e: @@ -343,6 +362,19 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option logger.error("Job '%s': %s", job["id"], msg) return msg + if into_history: + try: + from gateway.mirror import mirror_to_session + mirror_to_session( + platform_name, + chat_id, + cleaned_delivery_content, + source_label=f"cron:{job.get('name', job.get('id', '?'))}", + thread_id=thread_id, + ) + except Exception: + pass + logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id) return None @@ -810,6 +842,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: **Job ID:** {job_id} **Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')} **Schedule:** {job.get('schedule_display', 'N/A')} +**Deliver:** {job.get('deliver', 'local')} +**History:** {"session" if job.get("into_history", False) else "isolated"} ## Prompt @@ -832,6 +866,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: **Job ID:** {job_id} **Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')} **Schedule:** {job.get('schedule_display', 'N/A')} +**Deliver:** {job.get('deliver', 'local')} +**History:** {"session" if job.get("into_history", False) else "isolated"} ## Prompt diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index e0c9cf84602d..beefed754099 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1091,7 +1091,7 @@ async def _handle_delete_response(self, request: "web.Request") -> "web.Response _JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}") # Allowed fields for update — prevents clients injecting arbitrary keys - _UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"} + _UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "into_history", "skills", "skill", "repeat", "enabled"} _MAX_NAME_LENGTH = 200 _MAX_PROMPT_LENGTH = 5000 @@ -1141,6 +1141,7 @@ async def _handle_create_job(self, request: "web.Request") -> "web.Response": schedule = (body.get("schedule") or "").strip() prompt = body.get("prompt", "") deliver = body.get("deliver", "local") + into_history = body.get("into_history", False) skills = body.get("skills") repeat = body.get("repeat") @@ -1164,6 +1165,7 @@ async def _handle_create_job(self, request: "web.Request") -> "web.Response": "schedule": schedule, "name": name, "deliver": deliver, + "into_history": bool(into_history), } if skills: kwargs["skills"] = skills diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index e0ab6007a88e..8f87f542bf9e 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -16,6 +16,20 @@ from hermes_cli.colors import Colors, color +_KNOWN_CRON_FLAGS = ( + "--into-history", + "--no-into-history", + "--deliver", + "--name", + "--repeat", + "--skill", + "--add-skill", + "--remove-skill", + "--clear-skills", + "--script", +) + + def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]: if skills is None: if single_skill is None: @@ -38,6 +52,34 @@ def _cron_api(**kwargs): return json.loads(cronjob_tool(**kwargs)) +def _find_swallowed_cron_flags(prompt: Optional[str]) -> list[str]: + text = str(prompt or "") + return [flag for flag in _KNOWN_CRON_FLAGS if flag in text] + + +def _reject_swallowed_flags(prompt: Optional[str]) -> bool: + swallowed = _find_swallowed_cron_flags(prompt) + if not swallowed: + return False + + joined = ", ".join(swallowed) + print( + color( + "Cron prompt contains CLI flag text that looks like it was accidentally " + f"placed inside the prompt: {joined}", + Colors.RED, + ) + ) + print( + color( + "Move those flags outside the quoted prompt, for example:", + Colors.YELLOW, + ) + ) + print(" hermes cron create \"2m\" \"Reply with exactly: CRON HISTORY TEST 1\" --deliver origin --into-history") + return True + + def cron_list(show_all: bool = False): """List all scheduled jobs.""" from cron.jobs import list_jobs @@ -71,6 +113,7 @@ def cron_list(show_all: bool = False): if isinstance(deliver, str): deliver = [deliver] deliver_str = ", ".join(deliver) + history_mode = "session" if job.get("into_history") else "isolated" skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) if state == "paused": @@ -88,6 +131,7 @@ def cron_list(show_all: bool = False): print(f" Repeat: {repeat_str}") print(f" Next run: {next_run}") print(f" Deliver: {deliver_str}") + print(f" History: {history_mode}") if skills: print(f" Skills: {', '.join(skills)}") script = job.get("script") @@ -158,12 +202,16 @@ def cron_status(): def cron_create(args): + if _reject_swallowed_flags(getattr(args, "prompt", None)): + return 1 + result = _cron_api( action="create", schedule=args.schedule, prompt=args.prompt, name=getattr(args, "name", None), deliver=getattr(args, "deliver", None), + into_history=getattr(args, "into_history", None), repeat=getattr(args, "repeat", None), skill=getattr(args, "skill", None), skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), @@ -177,6 +225,7 @@ def cron_create(args): print(f" Schedule: {result['schedule']}") if result.get("skills"): print(f" Skills: {', '.join(result['skills'])}") + print(f" History: {'session' if result.get('into_history') else 'isolated'}") job_data = result.get("job", {}) if job_data.get("script"): print(f" Script: {job_data['script']}") @@ -192,6 +241,9 @@ def cron_edit(args): print(color(f"Job not found: {args.job_id}", Colors.RED)) return 1 + if _reject_swallowed_flags(getattr(args, "prompt", None)): + return 1 + existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")])) replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)) add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or [] @@ -215,6 +267,7 @@ def cron_edit(args): prompt=getattr(args, "prompt", None), name=getattr(args, "name", None), deliver=getattr(args, "deliver", None), + into_history=getattr(args, "into_history", None), repeat=getattr(args, "repeat", None), skills=final_skills, script=getattr(args, "script", None), @@ -231,6 +284,7 @@ def cron_edit(args): print(f" Skills: {', '.join(updated['skills'])}") else: print(" Skills: none") + print(f" History: {'session' if updated.get('into_history') else 'isolated'}") if updated.get("script"): print(f" Script: {updated['script']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e1c8cb1cc454..7f8cd3779e31 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4663,6 +4663,11 @@ def main(): cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction") cron_create.add_argument("--name", help="Optional human-friendly job name") cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id") + cron_create.add_argument("--into-history", dest="into_history", action="store_true", + help="Also mirror delivered output into the target chat's session history") + cron_create.add_argument("--no-into-history", dest="into_history", action="store_false", + help="Do not mirror delivered output into the target chat's session history") + cron_create.set_defaults(into_history=None) cron_create.add_argument("--repeat", type=int, help="Optional repeat count") cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.") cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run") @@ -4674,6 +4679,11 @@ def main(): cron_edit.add_argument("--prompt", help="New prompt/task instruction") cron_edit.add_argument("--name", help="New job name") cron_edit.add_argument("--deliver", help="New delivery target") + cron_edit.add_argument("--into-history", dest="into_history", action="store_true", + help="Mirror delivered output into the target chat's session history") + cron_edit.add_argument("--no-into-history", dest="into_history", action="store_false", + help="Stop mirroring delivered output into the target chat's session history") + cron_edit.set_defaults(into_history=None) cron_edit.add_argument("--repeat", type=int, help="New repeat count") cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.") cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.") diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index e0f56b9612ef..374c606efe42 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -233,6 +233,14 @@ def test_default_delivery_local_no_origin(self, tmp_cron_dir): job = create_job(prompt="Test", schedule="30m") assert job["deliver"] == "local" + def test_into_history_defaults_false(self, tmp_cron_dir): + job = create_job(prompt="Test", schedule="30m") + assert job["into_history"] is False + + def test_into_history_can_be_enabled(self, tmp_cron_dir): + job = create_job(prompt="Test", schedule="30m", into_history=True) + assert job["into_history"] is True + class TestUpdateJob: def test_update_name(self, tmp_cron_dir): @@ -275,6 +283,13 @@ def test_update_enable_disable(self, tmp_cron_dir): fetched = get_job(job["id"]) assert fetched["enabled"] is False + def test_update_into_history(self, tmp_cron_dir): + job = create_job(prompt="Toggle me", schedule="every 1h") + updated = update_job(job["id"], {"into_history": True}) + assert updated["into_history"] is True + fetched = get_job(job["id"]) + assert fetched["into_history"] is True + def test_update_nonexistent_returns_none(self, tmp_cron_dir): result = update_job("nonexistent_id", {"name": "X"}) assert result is None diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 08b57cfa897e..e1d0fa3a5016 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -209,7 +209,7 @@ def test_explicit_discord_channel_without_thread(self): class TestDeliverResultWrapping: - """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" + """Verify cron delivery wrapping and optional session mirroring behavior.""" def test_delivery_wraps_content_with_header_and_footer(self): """Delivered content should include task name header and agent-invisible note.""" @@ -284,6 +284,109 @@ def test_delivery_skips_wrapping_when_config_disabled(self): assert "Cronjob Response" not in sent_content assert "The agent cannot see" not in sent_content + def test_into_history_uses_history_footer_and_mirrors(self): + """Opt-in history mirroring should use the session-aware footer and mirror after send.""" + from gateway.config import Platform + + pconfig = MagicMock() + pconfig.enabled = True + mock_cfg = MagicMock() + mock_cfg.platforms = {Platform.TELEGRAM: pconfig} + + with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock: + job = { + "id": "test-job", + "name": "daily-report", + "deliver": "origin", + "into_history": True, + "origin": {"platform": "telegram", "chat_id": "123"}, + } + _deliver_result(job, "Here is today's summary.") + + sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1] + assert "added to the conversation history" in sent_content + assert "cannot see this message" not in sent_content + mirror_mock.assert_called_once_with( + "telegram", + "123", + sent_content, + source_label="cron:daily-report", + thread_id=None, + ) + + def test_into_history_does_not_mirror_on_delivery_error(self): + """Failed deliveries should not be mirrored into session history.""" + from gateway.config import Platform + + pconfig = MagicMock() + pconfig.enabled = True + mock_cfg = MagicMock() + mock_cfg.platforms = {Platform.TELEGRAM: pconfig} + + with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"error": "boom"})), \ + patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock: + job = { + "id": "test-job", + "deliver": "origin", + "into_history": True, + "origin": {"platform": "telegram", "chat_id": "123"}, + } + err = _deliver_result(job, "Output.") + + assert "delivery error" in err + mirror_mock.assert_not_called() + + def test_live_adapter_into_history_mirrors_after_send(self): + """Live-adapter deliveries should also mirror when into_history is enabled.""" + from gateway.config import Platform + from concurrent.futures import Future + + adapter = AsyncMock() + adapter.send.return_value = MagicMock(success=True) + + pconfig = MagicMock() + pconfig.enabled = True + mock_cfg = MagicMock() + mock_cfg.platforms = {Platform.DISCORD: pconfig} + + loop = MagicMock() + loop.is_running.return_value = True + + def fake_run_coro(coro, _loop): + future = Future() + future.set_result(MagicMock(success=True)) + coro.close() + return future + + with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ + patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \ + patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro), \ + patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock: + job = { + "id": "tts-job", + "name": "discord-report", + "deliver": "origin", + "into_history": True, + "origin": {"platform": "discord", "chat_id": "9876"}, + } + _deliver_result( + job, + "Here is TTS", + adapters={Platform.DISCORD: adapter}, + loop=loop, + ) + + mirror_mock.assert_called_once_with( + "discord", + "9876", + "Here is TTS", + source_label="cron:discord-report", + thread_id=None, + ) + def test_delivery_extracts_media_tags_before_send(self): """Cron delivery should pass MEDIA attachments separately to the send helper.""" from gateway.config import Platform diff --git a/tests/gateway/test_api_server_jobs.py b/tests/gateway/test_api_server_jobs.py index 6c17bb120b90..6cad8e968817 100644 --- a/tests/gateway/test_api_server_jobs.py +++ b/tests/gateway/test_api_server_jobs.py @@ -159,6 +159,26 @@ async def test_create_job(self, adapter): assert call_kwargs["schedule"] == "*/5 * * * *" assert call_kwargs["prompt"] == "do something" + @pytest.mark.asyncio + async def test_create_job_passes_into_history(self, adapter): + """POST /api/jobs forwards into_history to the cron create helper.""" + app = _create_app(adapter) + mock_create = MagicMock(return_value=SAMPLE_JOB) + async with TestClient(TestServer(app)) as cli: + with patch.object( + APIServerAdapter, "_CRON_AVAILABLE", True + ), patch.object( + APIServerAdapter, "_cron_create", mock_create + ): + resp = await cli.post("/api/jobs", json={ + "name": "test-job", + "schedule": "*/5 * * * *", + "into_history": True, + }) + assert resp.status == 200 + call_kwargs = mock_create.call_args[1] + assert call_kwargs["into_history"] is True + @pytest.mark.asyncio async def test_create_job_missing_name(self, adapter): """POST /api/jobs without name returns 400.""" diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 9ae920482728..f78e84f15c30 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -4,6 +4,7 @@ import pytest +from cli import HermesCLI from cron.jobs import create_job, get_job, list_jobs from hermes_cli.cron import cron_command @@ -105,3 +106,60 @@ def test_create_with_multiple_skills(self, tmp_cron_dir, capsys): assert len(jobs) == 1 assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"] assert jobs[0]["name"] == "Skill combo" + + def test_create_rejects_swallowed_cli_flags_in_prompt(self, tmp_cron_dir, capsys): + result = cron_command( + Namespace( + cron_command="create", + schedule="2m", + prompt="Reply with exactly: CRON HISTORY TEST 1 --into-history", + name="bad prompt", + deliver="origin", + into_history=None, + repeat=None, + skill=None, + skills=None, + script=None, + ) + ) + + out = capsys.readouterr().out + assert result == 1 + assert "accidentally placed inside the prompt" in out + assert len(list_jobs()) == 0 + + def test_create_allows_real_into_history_flag(self, tmp_cron_dir, capsys): + result = cron_command( + Namespace( + cron_command="create", + schedule="2m", + prompt="Reply with exactly: CRON HISTORY TEST 1", + name="good prompt", + deliver="origin", + into_history=True, + repeat=None, + skill=None, + skills=None, + script=None, + ) + ) + + out = capsys.readouterr().out + assert result == 0 + assert "Created job" in out + jobs = list_jobs() + assert len(jobs) == 1 + assert jobs[0]["into_history"] is True + + def test_slash_cron_add_parses_into_history_flag(self, tmp_cron_dir, capsys): + cli = HermesCLI.__new__(HermesCLI) + cli._handle_cron_command( + '/cron add 2m "Reply with exactly: TEST_HISTORY_201A" --deliver origin --into-history --name history-test-201a' + ) + + out = capsys.readouterr().out + assert "Created job" in out + jobs = list_jobs() + assert len(jobs) == 1 + assert jobs[0]["into_history"] is True + assert jobs[0]["deliver"] == "origin" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 8f746d1be902..86d539f6e838 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -192,6 +192,7 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: "schedule": job.get("schedule_display"), "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), + "into_history": bool(job.get("into_history", False)), "next_run_at": job.get("next_run_at"), "last_run_at": job.get("last_run_at"), "last_status": job.get("last_status"), @@ -214,6 +215,7 @@ def cronjob( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, + into_history: Optional[bool] = None, include_disabled: bool = False, skill: Optional[str] = None, skills: Optional[List[str]] = None, @@ -253,6 +255,7 @@ def cronjob( name=name, repeat=repeat, deliver=deliver, + into_history=bool(into_history), origin=_origin_from_env(), skills=canonical_skills, model=_normalize_optional_job_value(model), @@ -270,6 +273,7 @@ def cronjob( "schedule": job["schedule_display"], "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), + "into_history": bool(job.get("into_history", False)), "next_run_at": job["next_run_at"], "job": _format_job(job), "message": f"Cron job '{job['name']}' created.", @@ -331,6 +335,8 @@ def cronjob( updates["name"] = name if deliver is not None: updates["deliver"] = deliver + if into_history is not None: + updates["into_history"] = bool(into_history) if skills is not None or skill is not None: canonical_skills = _canonical_skills(skill, skills) updates["skills"] = canonical_skills @@ -457,6 +463,10 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: "type": "string", "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" }, + "into_history": { + "type": "boolean", + "description": "Optional. When true, also mirror the delivered cron output into the target gateway conversation history so future turns in that chat can see it. Default false." + }, "skills": { "type": "array", "items": {"type": "string"}, @@ -517,6 +527,7 @@ def check_cronjob_requirements() -> bool: name=args.get("name"), repeat=args.get("repeat"), deliver=args.get("deliver"), + into_history=args.get("into_history"), include_disabled=args.get("include_disabled", True), skill=args.get("skill"), skills=args.get("skills"), diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index 5eddcb7e8e77..4ff3e3c41a5d 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -184,7 +184,9 @@ The `[SILENT]` prefix in a cron response suppresses delivery entirely — useful ### Session Isolation -Cron deliveries are NOT mirrored into gateway session conversation history. They exist only in the cron job's own session. This prevents message alternation violations in the target chat's conversation. +By default, cron deliveries are NOT mirrored into gateway session conversation history. They exist only in the cron job's own session. This prevents message alternation violations in the target chat's conversation. + +Jobs can opt into post-delivery mirroring with `into_history: true`. In that mode, Hermes still runs the cron turn in its own isolated session, but after a successful delivery it appends the delivered text into the target gateway session transcript as a delivery mirror. That makes the output visible to future turns in that chat without changing the default isolation model. ## Recursion Guard diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 5e0dd02baf3a..ae88123f5d6b 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -42,6 +42,10 @@ hermes cron create "every 1h" "Use both skills and combine the result" \ --skill blogwatcher \ --skill find-nearby \ --name "Skill combo" + +hermes cron create "every 1h" "Post a status update and keep it in chat history" \ + --deliver origin \ + --into-history ``` ### Through natural conversation @@ -109,6 +113,8 @@ hermes cron edit --skill blogwatcher --skill find-nearby hermes cron edit --add-skill find-nearby hermes cron edit --remove-skill blogwatcher hermes cron edit --clear-skills +hermes cron edit --into-history +hermes cron edit --no-into-history ``` Notes: @@ -228,6 +234,19 @@ cron: wrap_response: false ``` +### Optional session-history mirroring + +By default, cron deliveries stay isolated from the target chat's conversation history. + +If you want future turns in that chat to be able to see the delivered cron output, create or edit the job with `--into-history`: + +```bash +hermes cron create "every 1h" "Post the hourly summary" --deliver origin --into-history +hermes cron edit --into-history +``` + +This mirrors the delivered message into the target gateway session after a successful send. It does **not** turn cron into a live heartbeat or trigger an immediate follow-up turn by itself — it just makes the delivered output visible to later replies in that conversation. + ### Silent suppression If the agent's final response starts with `[SILENT]`, delivery is suppressed entirely. The output is still saved locally for audit (in `~/.hermes/cron/output/`), but no message is sent to the delivery target.