mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(cron): tell the user TUI/CLI cron jobs are local-only at create time (#51683)
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.
This commit is contained in:
parent
a39283bf09
commit
8e7e104521
4 changed files with 147 additions and 2 deletions
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue