From 8e7e104521345fd5fa64764adf47763d963372c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:27:48 -0700 Subject: [PATCH] fix(cron): tell the user TUI/CLI cron jobs are local-only at create time (#51683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliver=origin (or omitted) from a TUI or classic-CLI session produces a job with origin=null, because those sessions never populate the HERMES_SESSION_PLATFORM/CHAT_ID context vars that _origin_from_env reads. The scheduler then resolves no delivery target and skips delivery — the job runs and saves output to last_output, but nothing reaches the user and they only find out by polling cronjob(action='list') (#51568). This is by design (local sessions have no live-delivery channel), so the fix surfaces it instead of silently dropping the intent: - cronjob create now appends an informational notice to its result when a created job resolves to zero delivery targets and the user did not explicitly ask for deliver='local'. The check uses the scheduler's own _resolve_delivery_targets so it accounts for origin, home channels, 'all', and explicit platform targets — no false positives. - PLATFORM_HINTS gains a 'tui' entry (the TUI had none) and the 'cli' hint now states that cron jobs from these sessions are local-only and that deliver must target a gateway-connected platform to notify the user. This stops the agent promising a delivery that never happens. No scheduler/delivery behavior change; no new env var; cron isolation invariant untouched. --- agent/prompt_builder.py | 19 +++++++- tests/agent/test_prompt_builder.py | 9 ++++ tests/tools/test_cronjob_tools.py | 78 ++++++++++++++++++++++++++++++ tools/cronjob_tools.py | 43 +++++++++++++++- 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index a731dbd1f0f..7f1986fbed0 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -709,7 +709,24 @@ PLATFORM_HINTS = { "(those are only intercepted on messaging platforms like Telegram, " "Discord, Slack, etc.; on the CLI they render as literal text). " "When referring to a file you created or changed, just state its " - "absolute path in plain text; the user can open it from there." + "absolute path in plain text; the user can open it from there. " + "Cron jobs scheduled from this session are LOCAL-ONLY: their output is " + "saved (viewable via cronjob action='list') but is NOT delivered back " + "into this terminal — there is no live-delivery channel here. If the " + "user wants to be notified when a job runs, the job's `deliver` must " + "target a gateway-connected messaging platform (e.g. deliver='telegram' " + "or 'all'). Do not promise the user that a deliver='origin' or " + "default-deliver cron job will message them in this session." + ), + "tui": ( + "You are running in the Hermes terminal UI (TUI). " + "Cron jobs scheduled from this session are LOCAL-ONLY: their output is " + "saved (viewable via cronjob action='list') but is NOT delivered back " + "into this TUI session — there is no live-delivery channel here. If the " + "user wants to be notified when a job runs, the job's `deliver` must " + "target a gateway-connected messaging platform (e.g. deliver='telegram' " + "or 'all'). Do not promise the user that a deliver='origin' or " + "default-deliver cron job will message them in this session." ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 6f0206dfbcb..f1d87c7fc14 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -990,9 +990,18 @@ class TestPromptBuilderConstants: assert "discord" in PLATFORM_HINTS assert "cron" in PLATFORM_HINTS assert "cli" in PLATFORM_HINTS + assert "tui" in PLATFORM_HINTS assert "api_server" in PLATFORM_HINTS assert "webui" in PLATFORM_HINTS + def test_cli_and_tui_hints_flag_local_only_cron(self): + """#51568 — cron jobs from CLI/TUI sessions don't deliver back into + the session, so the agent must be told up front not to promise it.""" + for key in ("cli", "tui"): + hint = PLATFORM_HINTS[key] + assert "LOCAL-ONLY" in hint + assert "deliver" in hint + def test_whatsapp_cloud_hint_mentions_24h_window(self): """The Cloud API's 24-hour conversation window is a hard rule the agent should know about. Phase 5 (template fallback) was deferred, diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 1ca877064a7..08c82f37513 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -503,3 +503,81 @@ class TestResolveModelOverride: ) assert provider == "custom:cliproxy" assert model == "gpt-5.4" + + +class TestLocalDeliveryNotice: + """#51568 — TUI/CLI cron jobs are local-only; surface that at create time + so the agent doesn't promise a delivery that never happens.""" + + @pytest.fixture(autouse=True) + def _setup_cron_dir(self, tmp_path, monkeypatch): + monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output") + # Default: no session origin (the TUI/CLI condition). + for var in ( + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_THREAD_ID", + "HERMES_SESSION_CHAT_NAME", + ): + monkeypatch.delenv(var, raising=False) + from gateway.session_context import clear_session_vars, set_session_vars + + tokens = set_session_vars() # reset ContextVars to empty + yield + clear_session_vars(tokens) + + def test_omitted_deliver_no_origin_emits_notice(self): + created = json.loads( + cronjob(action="create", prompt="Output the time", schedule="every 2m") + ) + assert created["success"] is True + # Omitted deliver from a session with no origin downgrades to local. + assert created["deliver"] == "local" + assert "local-only cron job" in created["message"] + assert "deliver='telegram'" in created["message"] + + def test_explicit_origin_no_origin_emits_notice(self): + created = json.loads( + cronjob( + action="create", prompt="x", schedule="every 2m", deliver="origin" + ) + ) + assert created["deliver"] == "origin" + assert "local-only cron job" in created["message"] + + def test_explicit_local_no_notice(self): + # The user explicitly asked for local — no surprise to flag. + created = json.loads( + cronjob( + action="create", prompt="x", schedule="every 2m", deliver="local" + ) + ) + assert created["deliver"] == "local" + assert "local-only cron job" not in created["message"] + + def test_explicit_platform_target_no_notice(self): + # An explicit platform:chat target resolves to a real delivery target. + created = json.loads( + cronjob( + action="create", + prompt="x", + schedule="every 2m", + deliver="telegram:123", + ) + ) + assert created["deliver"] == "telegram:123" + assert "local-only cron job" not in created["message"] + + def test_gateway_origin_no_notice(self, monkeypatch): + # With a captured gateway origin, omitted deliver becomes origin and + # resolves to that chat — nothing to warn about. + from gateway.session_context import set_session_vars + + set_session_vars(platform="telegram", chat_id="999") + created = json.loads( + cronjob(action="create", prompt="x", schedule="every 2m") + ) + assert created["deliver"] == "origin" + assert "local-only cron job" not in created["message"] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3339b823941..2af43c38169 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -298,6 +298,43 @@ def _origin_from_env() -> Optional[Dict[str, str]]: return None +def _local_delivery_notice(job: Dict[str, Any], user_deliver: Optional[str]) -> Optional[str]: + """Return an informational notice when a created job won't deliver anywhere. + + TUI/CLI sessions cannot be captured as a cron ``origin`` (no + ``HERMES_SESSION_PLATFORM``/``CHAT_ID`` is set for them), so a + ``deliver="origin"`` request — or an omitted ``deliver`` that defaults to + origin-or-local — produces a job that runs and saves output to + ``last_output`` but is never delivered back into the session. This is by + design (there is no live-delivery channel for local sessions), but silently + dropping the user's "tell me when it runs" intent is the trap reported in + #51568. Surface it at create time so the agent can relay it instead of + promising a delivery that never happens. + + Returns ``None`` when the user explicitly asked for ``local`` (no surprise), + or when the job resolves to a real delivery target. + """ + # An explicit local request is exactly what the user asked for — no notice. + if (user_deliver or "").strip().lower() == "local": + return None + try: + from cron.scheduler import _resolve_delivery_targets + + if _resolve_delivery_targets(job): + return None # Will actually deliver somewhere — nothing to flag. + except Exception: + # If resolution can't be evaluated, fall back to the origin signal. + if job.get("origin"): + return None + return ( + "This is a local-only cron job: its output is saved (view it with " + "cronjob(action='list')) but will NOT be delivered back into this " + "session — CLI/TUI sessions have no live-delivery channel. To be " + "notified when it runs, recreate or update the job with deliver set to " + "a gateway-connected platform, e.g. deliver='telegram' or deliver='all'." + ) + + def _repeat_display(job: Dict[str, Any]) -> str: times = (job.get("repeat") or {}).get("times") completed = (job.get("repeat") or {}).get("completed", 0) @@ -607,6 +644,10 @@ def cronjob( no_agent=_no_agent, ) _notify_provider_jobs_changed_safe() + _create_message = f"Cron job '{job['name']}' created." + _local_notice = _local_delivery_notice(job, _normalize_deliver_param(deliver)) + if _local_notice: + _create_message = f"{_create_message} {_local_notice}" return json.dumps( { "success": True, @@ -619,7 +660,7 @@ def cronjob( "deliver": job.get("deliver", "local"), "next_run_at": job["next_run_at"], "job": _format_job(job), - "message": f"Cron job '{job['name']}' created.", + "message": _create_message, }, indent=2, )