From a2da0ab797edf5e7ca7d3f64591facbbc37ec7f2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:38:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(cron):=20bot-chat=20delivery=20target=20?= =?UTF-8?q?=E2=80=94=20cron=20output=20lands=20in=20a=20bot's=20canonical?= =?UTF-8?q?=20Bot=20Chat=20and=20the=20bot=20responds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliver='bot-chat[:]' is a machine-local pseudo-platform: the scheduler delivers job output as a real inbound turn in the target profile's canonical Bot Chat via the chat CLI lane (--in ~ -c "Bot Chat" --create-if-missing -Q --query-file), the same lane Bot Mode agent-to-agent messages use. The bot reads the output, acts on it, and responds in its chat — instead of the output only landing in Run history. - cron/scheduler.py: token parsing, target resolution (own profile / named local profile / unknown -> skipped with warning), subprocess delivery lane with cron.bot_chat_delivery_timeout_seconds (default 600s), preflight exemption, and bot-chat entries in cron_delivery_targets() for UI pickers. Excluded from 'all' by design. - tools/cronjob_tools.py: create/update-time validation — named profiles must exist on this machine (fail at create, not at 3am); deliver schema documents the new token. - tui_gateway/methods_tools.py: cron.manage add forwards deliver. - hermes_cli/profiles.py: list_profile_names() cheap name-only scan. - hermes-bots plugin: Create Cronjob dialog gains a 'Send results to' picker (Run history only / 's chat); bot-chat jobs send the BARE token on the profile-scoped create so Desktop-side aliases can never name a profile the backend doesn't have. - Docs: user cron guide, automate-with-cron, cron-internals. Machine-local by construction: names resolve only against the executing machine's ~/.hermes/profiles/, so overlapping profile names across multiple connected gateways are unambiguous. --- .../desktop/src/plugins/hermes-bots/plugin.js | 19 +- .../tests/routine-deliver-target.test.mjs | 31 +++ cron/scheduler.py | 223 ++++++++++++++++++ hermes_cli/profiles.py | 19 ++ hermes_cli/subcommands/cron.py | 6 +- tests/cron/test_cron_bot_chat_delivery.py | 216 +++++++++++++++++ tools/cronjob_tools.py | 45 +++- tui_gateway/methods_tools.py | 5 + .../docs/developer-guide/cron-internals.md | 3 + website/docs/guides/automate-with-cron.md | 22 ++ website/docs/user-guide/features/cron.md | 23 ++ 11 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/routine-deliver-target.test.mjs create mode 100644 tests/cron/test_cron_bot_chat_delivery.py diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index f0d84a315a..07b3df6e54 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -8935,6 +8935,11 @@ function CreateRoutineDialog({ bot, open, onClose }) { const [instruction, setInstruction] = useState('') const [sched, setSched] = useState(defaultScheduleState()) const [continuity, setContinuity] = useState(false) + // Where the run's output lands: 'history' = the run session only (Run + // history / cron page, today's behavior); 'bot-chat' = inject into this + // bot's canonical Bot Chat as a real message — the bot reads it, acts on + // it, and responds there (costs the bot one agent turn per run). + const [target, setTarget] = useState('history') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const activeProfile = useValue(host.state.profile) @@ -8945,6 +8950,7 @@ function CreateRoutineDialog({ bot, open, onClose }) { setInstruction('') setSched(defaultScheduleState()) setContinuity(false) + setTarget('history') setBusy(false) setError(null) } @@ -8978,7 +8984,11 @@ function CreateRoutineDialog({ bot, open, onClose }) { prompt: routinePrompt(bot, title, task, activeProfile), ...(bot ? { profile: bot } : {}), ...(repeatN ? { repeat: repeatN } : {}), - ...(continuity ? { continuity: true } : {}) + ...(continuity ? { continuity: true } : {}), + // 'bot-chat' (bare, no name): the job is created IN the bot's own + // cron store (profile scoping above), so the scheduler resolves the + // token to that profile — no cross-gateway name ambiguity possible. + ...(target === 'bot-chat' ? { deliver: 'bot-chat' } : {}) }) await invalidateRoutineOwner(bot) host.notify({ kind: 'success', message: `Cronjob "${title}" scheduled` }) @@ -9031,6 +9041,13 @@ function CreateRoutineDialog({ bot, open, onClose }) { }) ), labeled('When to run', jsx(SchedulePicker, { state: sched, setState: setSched })), + labeled( + 'Send results to', + pickerSelect(target, setTarget, [ + { id: 'history', label: 'Run history only' }, + { id: 'bot-chat', label: `${displayName({ name: bot }, $botMeta.get()[bot])}\u2019s chat (bot responds)` } + ]) + ), jsxs('label', { className: 'flex items-center gap-2 text-xs text-(--ui-text-tertiary) cursor-pointer select-none', children: [ diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routine-deliver-target.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routine-deliver-target.test.mjs new file mode 100644 index 0000000000..4e4fe79d0c --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/routine-deliver-target.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +// The Create Cronjob dialog's "Send results to" target picker: source-shape +// tests in the style of the sibling routine tests (the plugin is a single +// direct file; behavior contracts are pinned via source assertions where a +// full DOM harness would be heavier than the seam warrants). +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +test('dialog offers a delivery target picker with history and bot-chat options', () => { + assert.match(pluginSource, /Send results to/) + assert.match(pluginSource, /id: 'history', label: 'Run history only'/) + assert.match(pluginSource, /id: 'bot-chat'/) +}) + +test('bot-chat target sends the BARE deliver token on the profile-scoped create', () => { + // The job is created in the bot's own cron store (profile: bot), so the + // bare token resolves to that profile machine-locally — a named token + // built from a Desktop-side alias could name a profile the backend does + // not have (the #82530 alias trap). Pin the bare form. + assert.match(pluginSource, /\.\.\.\(target === 'bot-chat' \? \{ deliver: 'bot-chat' \} : \{\}\)/) + assert.doesNotMatch(pluginSource, /deliver: `bot-chat:\$\{/) +}) + +test('history target (default) sends no deliver param — behavior unchanged', () => { + assert.match(pluginSource, /useState\('history'\)/) + // reset() returns the picker to the default so a reopened dialog never + // inherits the previous create's target. + assert.match(pluginSource, /setTarget\('history'\)/) +}) diff --git a/cron/scheduler.py b/cron/scheduler.py index dd82623ea1..082cdaedd1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2254,6 +2254,25 @@ def cron_delivery_targets() -> list[dict]: "home_env_var": env_var or None, } ) + + # Bot Chat targets: one per local profile. Machine-local by design (the + # scheduler delivers via a local chat subprocess), so the names listed + # here are exactly the names that resolve at fire time — no gateway + # config, no home channel needed. + try: + from hermes_cli.profiles import list_profile_names + + for profile_name in list_profile_names(): + targets.append( + { + "id": f"{BOT_CHAT_PLATFORM}:{profile_name}", + "name": f"Bot Chat ({profile_name})", + "home_target_set": True, + "home_env_var": None, + } + ) + except Exception: + logger.debug("cron_delivery_targets: profile listing unavailable", exc_info=True) return targets @@ -2295,6 +2314,13 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d if deliver_value == "local": return None + # bot-chat[:] — checked before the generic platform:chat_id + # split below so the profile-name argument is never misparsed as a + # chat_id on an unknown platform. + bot_chat_profile = parse_bot_chat_deliver_token(deliver_value) + if bot_chat_profile is not None: + return _resolve_bot_chat_target(job, bot_chat_profile) + if deliver_value == "origin": if origin: return { @@ -2390,6 +2416,126 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d } +def _get_bot_chat_delivery_timeout() -> int: + """Timeout for one bot-chat delivery turn (the target bot runs a full + agent turn on the injected output, so this is minutes, not seconds). + + ``cron.bot_chat_delivery_timeout_seconds`` in config.yaml; default 600. + """ + try: + cfg = load_config() + value = int(cfg.get("cron", {}).get("bot_chat_delivery_timeout_seconds", 600)) + return value if value > 0 else 600 + except Exception: + return 600 + + +def _deliver_to_bot_chat(job: dict, content: str, profile: str) -> Optional[str]: + """Deliver job output into a profile's canonical Bot Chat as an inbound turn. + + Runs ``hermes [-p ] chat --in ~ -c "Bot Chat" --create-if-missing + -Q --query-file `` — the exact lane Bot Mode agent-to-agent messages + use, so the adopt-before-mint canonical-session rules apply and the target + bot receives the output as a real user-role message it can act on. + Alternation-safe by construction: this is an inbound turn on the chat + command lane, not a transcript splice. + + ``profile`` is ``""`` for the job's own profile (subprocess inherits this + scheduler's HERMES_HOME) or a validated local profile name. Returns None + on success or an error string for ``last_delivery_error``. + """ + import shutil as _shutil + import tempfile + + job_id = job.get("id", "?") + job_name = job.get("name", job_id) + + hermes_bin = _shutil.which("hermes") + if hermes_bin: + argv = [hermes_bin] + else: + try: + import importlib.util as _ilu + + if _ilu.find_spec("hermes_cli") is not None: + argv = [sys.executable, "-m", "hermes_cli.main"] + else: + return "bot-chat delivery failed: hermes CLI not resolvable" + except Exception: + return "bot-chat delivery failed: hermes CLI not resolvable" + + env = os.environ.copy() + if profile: + argv += ["-p", profile] + # -p owns profile resolution in the child; a leftover HERMES_HOME + # from THIS scheduler's profile must not shadow it. + env.pop("HERMES_HOME", None) + + # The prefix tells the receiving bot this is scheduled output, not the + # human typing — mirrors the Bot Mode sender-attribution convention. + message = ( + f'[Cronjob "{job_name}" output — scheduled job, not the user. ' + f"Review it, act on anything that needs action, and summarize " + f"for the chat.]\n\n{content}" + ) + + query_file = None + try: + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=".txt", prefix="hermes-cron-botchat-", + delete=False, + ) as fh: + fh.write(message) + query_file = fh.name + + argv += [ + "chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", + "-Q", "--query-file", query_file, + ] + + result = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=_get_bot_chat_delivery_timeout(), + env=env, + creationflags=windows_hide_flags(), + ) + if result.returncode != 0: + tail = (result.stderr or result.stdout or "").strip()[-500:] + msg = ( + f"bot-chat delivery to profile " + f"'{profile or '(own)'}' failed (exit {result.returncode})" + + (f": {tail}" if tail else "") + ) + logger.warning("Job '%s': %s", job_id, msg) + return msg + logger.info( + "Job '%s': delivered to Bot Chat of profile '%s'", + job_id, profile or "(own)", + ) + return None + except subprocess.TimeoutExpired: + msg = ( + f"bot-chat delivery to profile '{profile or '(own)'}' timed out " + f"after {_get_bot_chat_delivery_timeout()}s (the bot's turn may " + "still complete; raise cron.bot_chat_delivery_timeout_seconds if " + "this recurs)" + ) + logger.warning("Job '%s': %s", job_id, msg) + return msg + except Exception as e: + msg = f"bot-chat delivery failed: {str(e) or type(e).__name__}" + logger.warning("Job '%s': %s", job_id, msg, exc_info=True) + return msg + finally: + if query_file: + try: + os.unlink(query_file) + except OSError: + pass + + def _normalize_deliver_value(deliver) -> str: """Normalize a stored/submitted ``deliver`` value to its canonical string form. @@ -2416,6 +2562,67 @@ def _normalize_deliver_value(deliver) -> str: # (those with a configured home chat_id) in _expand_routing_tokens. _ROUTING_TOKENS = frozenset({"all"}) +# Pseudo-platform for delivering job output INTO a profile's canonical +# "Bot Chat" session as a real inbound turn (the bot sees it, runs a turn, +# and can respond — Bot Mode's agent-to-agent lane, not a transcript +# mirror). ``bot-chat`` targets the job's own profile; ``bot-chat:`` +# targets a named profile on THIS machine. Deliberately excluded from the +# ``all`` routing token: ``all`` fans out to messaging home channels, and a +# bot-chat delivery costs a full agent turn. +BOT_CHAT_PLATFORM = "bot-chat" + + +def parse_bot_chat_deliver_token(part: str) -> Optional[str]: + """Return the target profile for a ``bot-chat[:]`` deliver token. + + Returns ``""`` for the bare token (the job's own profile), the profile + name for the explicit form, or ``None`` when ``part`` is not a bot-chat + token at all. Case-insensitive on the token; the profile name is + normalized by the profile layer at resolve time. + """ + raw = (part or "").strip() + lowered = raw.lower() + if lowered == BOT_CHAT_PLATFORM: + return "" + prefix = BOT_CHAT_PLATFORM + ":" + if lowered.startswith(prefix): + return raw[len(prefix):].strip() + return None + + +def _resolve_bot_chat_target(job: dict, profile_arg: str) -> Optional[dict]: + """Resolve a bot-chat deliver token to a concrete delivery target. + + ``profile_arg`` is ``""`` for the job's own profile (the HERMES_HOME + this scheduler runs under — machine-local and self-referential, so no + ``-p`` flag is needed at send time) or an explicit profile name that + must exist in THIS machine's profile root. Cross-machine delivery is + intentionally unsupported: names resolve only against the local + ``~/.hermes/profiles/`` tree, so same-named profiles on other gateways + can never be targeted by accident. + """ + if not profile_arg: + # Own profile: chat subprocess inherits HERMES_HOME, no name needed. + return {"platform": BOT_CHAT_PLATFORM, "chat_id": "", "thread_id": None} + try: + from hermes_cli.profiles import normalize_profile_name, profile_exists + + canon = normalize_profile_name(profile_arg) + if not profile_exists(canon): + logger.warning( + "Job '%s': bot-chat delivery profile '%s' not found on this " + "machine — skipping target", + job.get("id", "?"), profile_arg, + ) + return None + return {"platform": BOT_CHAT_PLATFORM, "chat_id": canon, "thread_id": None} + except Exception: + logger.warning( + "Job '%s': failed to resolve bot-chat profile '%s'", + job.get("id", "?"), profile_arg, exc_info=True, + ) + return None + def _expand_routing_tokens(part: str) -> List[str]: """Expand a routing-intent token to concrete platform names. @@ -2771,6 +2978,17 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option chat_id = target["chat_id"] thread_id = target.get("thread_id") + # bot-chat targets don't ride a gateway adapter: the output becomes a + # real inbound turn in the target profile's canonical Bot Chat via the + # chat CLI lane (the same one Bot Mode agent-to-agent sends use). The + # bot runs a turn and can respond — handled before the Platform enum + # below, which knows nothing about this pseudo-platform. + if platform_name == BOT_CHAT_PLATFORM: + bot_chat_error = _deliver_to_bot_chat(job, content, chat_id) + if bot_chat_error: + delivery_errors.append(bot_chat_error) + continue + # Diagnostic: log thread_id for topic-aware delivery debugging origin = _resolve_origin(job) or {} origin_thread = origin.get("thread_id") @@ -4561,6 +4779,11 @@ def _preflight_check_delivery(job: dict) -> Optional[str]: part = part.strip() if not part or part.lower() in {"local", "origin", "all"}: continue + # bot-chat targets need no gateway credentials — they deliver via a + # local chat subprocess. Unknown-profile failures surface per run in + # last_delivery_error (and are validated at create time). + if parse_bot_chat_deliver_token(part) is not None: + continue platform_parts.append(part.split(":", 1)[0].strip()) if not platform_parts: return None diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index f6e3c79f30..6e83a824f3 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -387,6 +387,25 @@ def profile_exists(name: str) -> bool: return get_profile_dir(canon).is_dir() +def list_profile_names() -> List[str]: + """Cheap name-only profile listing: ``default`` plus profile dirs. + + Unlike :func:`list_profiles` this reads NO per-profile config/metadata — + it is a directory scan, safe to call from hot paths (cron delivery-target + listings, create-time validation). + """ + names = ["default"] + profiles_root = _get_profiles_root() + try: + if profiles_root.is_dir(): + for entry in sorted(profiles_root.iterdir()): + if entry.is_dir() and entry.name != "default" and _PROFILE_ID_RE.match(entry.name): + names.append(entry.name) + except OSError: + pass + return names + + # --------------------------------------------------------------------------- # Alias / wrapper script management # --------------------------------------------------------------------------- diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index 73acc073c6..d617007b8b 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -36,7 +36,11 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: 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", + help=( + "Delivery target: origin, local, telegram, discord, signal, " + "platform:chat_id, or bot-chat[:profile] (inject output into a " + "local profile's canonical Bot Chat as a message the bot responds to)" + ), ) cron_create.add_argument("--repeat", type=int, help="Optional repeat count") cron_create.add_argument( diff --git a/tests/cron/test_cron_bot_chat_delivery.py b/tests/cron/test_cron_bot_chat_delivery.py new file mode 100644 index 0000000000..92ebadde49 --- /dev/null +++ b/tests/cron/test_cron_bot_chat_delivery.py @@ -0,0 +1,216 @@ +"""Bot Chat cron delivery: deliver='bot-chat[:]' injects job output +into a local profile's canonical Bot Chat session as a real inbound turn. + +Covers token parsing, target resolution (own profile / named / missing), +preflight exemption, create-time validation, the subprocess delivery lane, +and the delivery-targets listing used by UI pickers. +""" + +import subprocess +from unittest import mock + +import pytest + +from cron import scheduler as sched +from cron.scheduler import ( + BOT_CHAT_PLATFORM, + _deliver_to_bot_chat, + _preflight_check_delivery, + _resolve_bot_chat_target, + _resolve_delivery_targets, + parse_bot_chat_deliver_token, +) + + +# ── token parsing ──────────────────────────────────────────────────────────── + +def test_bare_token_targets_own_profile(): + assert parse_bot_chat_deliver_token("bot-chat") == "" + assert parse_bot_chat_deliver_token(" Bot-Chat ") == "" + + +def test_named_token_returns_profile(): + assert parse_bot_chat_deliver_token("bot-chat:research") == "research" + assert parse_bot_chat_deliver_token("BOT-CHAT:Research") == "Research" + + +def test_non_bot_chat_tokens_pass_through(): + assert parse_bot_chat_deliver_token("telegram:-100:17") is None + assert parse_bot_chat_deliver_token("origin") is None + assert parse_bot_chat_deliver_token("local") is None + assert parse_bot_chat_deliver_token("all") is None + # A platform whose name merely CONTAINS bot-chat must not match. + assert parse_bot_chat_deliver_token("bot-chatter") is None + + +# ── target resolution ──────────────────────────────────────────────────────── + +def test_own_profile_resolves_without_name(): + target = _resolve_bot_chat_target({"id": "j1"}, "") + assert target == {"platform": BOT_CHAT_PLATFORM, "chat_id": "", "thread_id": None} + + +def test_named_profile_resolves_when_exists(): + with mock.patch("hermes_cli.profiles.profile_exists", return_value=True): + target = _resolve_bot_chat_target({"id": "j1"}, "research") + assert target is not None + assert target["platform"] == BOT_CHAT_PLATFORM + assert target["chat_id"] == "research" + + +def test_unknown_profile_resolves_to_none(): + with mock.patch("hermes_cli.profiles.profile_exists", return_value=False): + assert _resolve_bot_chat_target({"id": "j1"}, "ghost") is None + + +def test_resolve_delivery_targets_combines_with_platform_targets(): + """bot-chat rides the same comma-separated deliver string as platforms.""" + job = {"id": "j1", "deliver": "bot-chat,telegram"} + with mock.patch.object(sched, "_get_home_target_chat_id", return_value="-100123"), \ + mock.patch.object(sched, "_get_home_target_thread_id", return_value=None), \ + mock.patch.object(sched, "_is_known_delivery_platform", return_value=True), \ + mock.patch.object(sched, "_resolve_origin", return_value=None): + targets = _resolve_delivery_targets(job) + platforms = {t["platform"] for t in targets} + assert BOT_CHAT_PLATFORM in platforms + assert "telegram" in platforms + + +# ── preflight ──────────────────────────────────────────────────────────────── + +def test_preflight_ignores_bot_chat_targets(): + """bot-chat needs no gateway credentials — preflight must not block it.""" + assert _preflight_check_delivery({"id": "j1", "deliver": "bot-chat"}) is None + assert _preflight_check_delivery({"id": "j1", "deliver": "bot-chat:research"}) is None + + +def test_preflight_still_blocks_unknown_platforms(): + with mock.patch.object(sched, "_is_known_delivery_platform", return_value=False): + err = _preflight_check_delivery({"id": "j1", "deliver": "nonexistent-platform"}) + assert err is not None and "not a known" in err + + +# ── create-time validation ─────────────────────────────────────────────────── + +def test_create_validation_rejects_unknown_profile(): + from tools.cronjob_tools import _validate_bot_chat_deliver + + with mock.patch("hermes_cli.profiles.profile_exists", return_value=False): + err = _validate_bot_chat_deliver("bot-chat:ghost") + assert err is not None + assert "machine-local" in err + + +def test_create_validation_accepts_bare_and_existing(): + from tools.cronjob_tools import _validate_bot_chat_deliver + + assert _validate_bot_chat_deliver("bot-chat") is None + assert _validate_bot_chat_deliver(None) is None + assert _validate_bot_chat_deliver("telegram:-100") is None + with mock.patch("hermes_cli.profiles.profile_exists", return_value=True): + assert _validate_bot_chat_deliver("bot-chat:research") is None + + +# ── delivery lane ──────────────────────────────────────────────────────────── + +def _completed(returncode=0, stderr=""): + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout="", stderr=stderr) + + +def test_deliver_runs_canonical_bot_chat_lane(): + """The subprocess must use the Bot Mode agent-to-agent chat lane: + chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file .""" + calls = {} + + def fake_run(argv, **kwargs): + calls["argv"] = argv + calls["kwargs"] = kwargs + return _completed() + + with mock.patch.object(sched.subprocess, "run", side_effect=fake_run), \ + mock.patch.object(sched.shutil, "which", return_value="/usr/bin/hermes"): + err = _deliver_to_bot_chat({"id": "j1", "name": "Daily digest"}, "the output", "") + + assert err is None + argv = calls["argv"] + assert argv[0] == "/usr/bin/hermes" + assert "-p" not in argv # own profile: subprocess inherits HERMES_HOME + assert "chat" in argv + assert "Bot Chat" in argv + assert "--create-if-missing" in argv + assert "-Q" in argv + assert "--query-file" in argv + # Message rides a temp file, never inline argv (quote/expansion safety). + assert not any("the output" in str(a) for a in argv) + + +def test_deliver_named_profile_uses_p_flag_and_clears_home(): + calls = {} + + def fake_run(argv, **kwargs): + calls["argv"] = argv + calls["kwargs"] = kwargs + return _completed() + + with mock.patch.object(sched.subprocess, "run", side_effect=fake_run), \ + mock.patch.object(sched.shutil, "which", return_value="/usr/bin/hermes"), \ + mock.patch.dict(sched.os.environ, {"HERMES_HOME": "/tmp/other-profile"}): + err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "research") + + assert err is None + argv = calls["argv"] + assert argv[1:3] == ["-p", "research"] + # -p owns resolution; the scheduler's own HERMES_HOME must not leak in. + assert "HERMES_HOME" not in calls["kwargs"]["env"] + + +def test_deliver_failure_returns_error_string(): + with mock.patch.object( + sched.subprocess, "run", return_value=_completed(returncode=1, stderr="boom") + ), mock.patch.object(sched.shutil, "which", return_value="/usr/bin/hermes"): + err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") + assert err is not None + assert "boom" in err + + +def test_deliver_timeout_returns_error_string(): + with mock.patch.object( + sched.subprocess, "run", + side_effect=subprocess.TimeoutExpired(cmd="hermes", timeout=600), + ), mock.patch.object(sched.shutil, "which", return_value="/usr/bin/hermes"): + err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") + assert err is not None + assert "timed out" in err + + +def test_deliver_message_carries_cron_attribution(tmp_path): + """The injected turn must self-identify as scheduled output, not the user.""" + captured = {} + + def fake_run(argv, **kwargs): + qf = argv[argv.index("--query-file") + 1] + with open(qf, encoding="utf-8") as fh: + captured["message"] = fh.read() + return _completed() + + with mock.patch.object(sched.subprocess, "run", side_effect=fake_run), \ + mock.patch.object(sched.shutil, "which", return_value="/usr/bin/hermes"): + _deliver_to_bot_chat({"id": "j1", "name": "Daily digest"}, "the payload", "") + + assert 'Cronjob "Daily digest" output' in captured["message"] + assert "not the user" in captured["message"] + assert "the payload" in captured["message"] + + +# ── delivery-targets listing (UI pickers) ──────────────────────────────────── + +def test_delivery_targets_include_local_profiles(): + with mock.patch("hermes_cli.profiles.list_profile_names", + return_value=["default", "research"]): + targets = sched.cron_delivery_targets() + ids = [t["id"] for t in targets] + assert f"{BOT_CHAT_PLATFORM}:default" in ids + assert f"{BOT_CHAT_PLATFORM}:research" in ids + bot_chat_entries = [t for t in targets if t["id"].startswith(BOT_CHAT_PLATFORM)] + # No gateway home channel needed for bot-chat targets. + assert all(t["home_target_set"] for t in bot_chat_entries) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index ae507b75c7..f9ef0fc077 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -461,6 +461,40 @@ def _normalize_deliver_param(value: Any) -> Optional[str]: return text or None +def _validate_bot_chat_deliver(deliver: Optional[str]) -> Optional[str]: + """Validate any ``bot-chat[:]`` deliver elements at create time. + + Bot Chat delivery is machine-local: the named profile must exist on THIS + machine (the one whose scheduler will fire the job). Failing loudly here + beats a per-run ``last_delivery_error`` at 3am — especially for Desktop + clients whose merged multi-gateway rosters may show same-named profiles + from other machines. Returns an error string or None. + """ + if not deliver: + return None + try: + from cron.scheduler import parse_bot_chat_deliver_token + from hermes_cli.profiles import normalize_profile_name, profile_exists + except Exception: + return None # validation is best-effort; resolution re-checks at fire time + for part in str(deliver).split(","): + profile_arg = parse_bot_chat_deliver_token(part.strip()) + if profile_arg is None or not profile_arg: + continue # not a bot-chat token, or bare token (own profile) + try: + canon = normalize_profile_name(profile_arg) + except Exception: + return f"invalid bot-chat profile name '{profile_arg}'" + if not profile_exists(canon): + return ( + f"bot-chat delivery profile '{profile_arg}' not found on this " + "gateway's machine. Bot Chat delivery is machine-local — use a " + "profile that exists here (hermes profile list), or omit the " + "name (deliver='bot-chat') for the job's own profile." + ) + return None + + def _resolve_cron_context_deliver(deliver: Optional[str]) -> Optional[str]: """Resolve ``origin`` to a concrete target for cron-context creates. @@ -1265,6 +1299,12 @@ def cronjob( if base_url_error: return tool_error(base_url_error, success=False) + # bot-chat deliver targets are machine-local: named profiles must + # exist here, and a bad name should fail the CREATE, not the run. + bot_chat_error = _validate_bot_chat_deliver(_normalize_deliver_param(deliver)) + if bot_chat_error: + return tool_error(bot_chat_error, success=False) + # Validate context_from references existing jobs if context_from: from cron.jobs import get_job as _get_job @@ -1489,6 +1529,9 @@ def cronjob( if name is not None: updates["name"] = name if deliver is not None: + bot_chat_error = _validate_bot_chat_deliver(_normalize_deliver_param(deliver)) + if bot_chat_error: + return tool_error(bot_chat_error, success=False) updates["deliver"] = _resolve_cron_context_deliver( _normalize_deliver_param(deliver) ) @@ -1684,7 +1727,7 @@ Scheduling from cron-run sessions is disabled by default and enabled via cron.al }, "deliver": { "type": "string", - "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time, so a job created before a channel was wired up will pick it up automatically once connected." + "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), 'bot-chat' (inject the output into this profile's canonical Bot Chat as a real message — the bot reads it, acts on it, and responds in that chat; 'bot-chat:' targets another local profile's Bot Chat, costing that bot an agent turn per run), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all', 'bot-chat:research'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time (and never includes bot-chat targets), so a job created before a channel was wired up will pick it up automatically once connected." }, "skills": { "type": "array", diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index cece5fbbe4..f54e6f0582 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1752,6 +1752,11 @@ def _(rid, params: dict) -> dict: if params.get("continuity") is not None else None ), + # Optional delivery target — notably 'bot-chat[:name]' + # (canonical Bot Chat injection) from the Desktop Bot + # Mode cronjob dialog. Omitted/empty keeps the + # cronjob() default. + deliver=(str(params.get("deliver") or "").strip() or None), ) ), ) diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index a18c1a9dd6..427692eb92 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -254,6 +254,7 @@ Most platforms also accept an optional thread/topic as a third segment: `platfor | WeCom | `wecom` or `wecom:` | Bare name delivers to WeCom | | BlueBubbles | `bluebubbles` or `bluebubbles:` | Bare name delivers to iMessage via BlueBubbles | | QQ Bot | `qqbot` or `qqbot:` | Bare name delivers to QQ (Tencent) via Official API v2 | +| Bot Chat | `bot-chat` or `bot-chat:` | Inject into a local profile's canonical Bot Chat (the bot responds) | Platforms in the first group have explicit, validated target syntax — named channels (`#channel`), topics/threads, room/user IDs, group IDs, or phone numbers. The remaining platforms accept the generic `platform:` form (the value after the colon is used verbatim as the destination ID); a bare platform name always delivers to the home channel. @@ -261,6 +262,8 @@ Platforms in the first group have explicit, validated target syntax — named ch For **Telegram topics**, use `telegram::` (e.g., `telegram:-1001234567890:17585`). For **Slack threads**, the third segment is the parent message's `thread_ts` (e.g., `slack:C0123ABCD45:1700000000.000100`), so it only applies when replying under an existing message. +**Bot Chat** (`bot-chat`, `bot-chat:`) is a machine-local pseudo-platform, not a gateway adapter: the scheduler delivers by running `hermes [-p ] chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file ` — the same lane Bot Mode agent-to-agent messages use — so the output arrives as a real inbound turn in the profile's canonical Bot Chat and the bot runs a full agent turn on it (alternation-safe by construction; this is the chat command lane, not a transcript mirror). The bare token targets the job's own profile; the named form is validated against `~/.hermes/profiles/` at create time and again at fire time, and never resolves across machines. Bot-chat targets are excluded from the `all` routing token and from delivery preflight (no gateway credentials involved). The per-delivery subprocess timeout is `cron.bot_chat_delivery_timeout_seconds` (default 600). + ### Response Wrapping By default (`cron.wrap_response: true`), cron deliveries are wrapped with: diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md index a1a787fe88..20bb490207 100644 --- a/website/docs/guides/automate-with-cron.md +++ b/website/docs/guides/automate-with-cron.md @@ -246,6 +246,28 @@ The `--deliver` flag controls where results go: | `slack` | `--deliver slack` | Your Slack home channel | | Specific chat | `--deliver telegram:-1001234567890` | A specific Telegram group | | Threaded | `--deliver telegram:-1001234567890:17585` | A specific Telegram topic thread | +| Bot Chat | `--deliver bot-chat` | Inject output into this profile's canonical Bot Chat — the bot reads it and responds | +| Bot Chat (named) | `--deliver bot-chat:research` | Another local profile's Bot Chat | + +### Bot Chat delivery + +`bot-chat` targets deliver the job's output **into a profile's canonical "Bot +Chat" session as a real message** — the bot receives it like any other message, +acts on anything that needs action, and responds in that chat. This is the +target to use when you want a bot to *see and react to* scheduled output +instead of just having it archived in Run history. + +Things to know: + +- **Machine-local.** The profile must exist on the machine running the + scheduler (`hermes profile list`). Names are validated at create time; + profiles on other gateways/machines cannot be targeted. +- **Costs a bot turn.** Each delivery runs a full agent turn in the target + bot's Bot Chat — budget accordingly for high-frequency jobs. +- **Combinable.** `--deliver bot-chat,telegram` posts to the bot AND your + Telegram home channel. The `all` token never expands to bot-chat targets. +- The delivered message is prefixed so the bot knows it came from a scheduled + job, not from you. --- diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index fa2efc863c..ab1e185f14 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -374,12 +374,23 @@ When scheduling jobs, you specify where the output goes: | `"weixin"` | Weixin (WeChat) | | | `"bluebubbles"` | BlueBubbles (iMessage) | | | `"qqbot"` | QQ Bot (Tencent QQ) | | +| `"bot-chat"` | This profile's canonical Bot Chat — the bot reads the output and responds | Machine-local | +| `"bot-chat:research"` | Another local profile's Bot Chat | Validated at create time | | `"all"` | Fan out to every connected home channel | Resolved at fire time | | `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list | | `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens | The agent's final response is automatically delivered to the configured `deliver:` target — the agent does not send messages itself, so there is nothing to call in the cron prompt. +### Bot Chat delivery (`bot-chat`) + +`bot-chat` delivers the output **into a profile's canonical "Bot Chat" session as a real message**. Unlike every other target — where the recipient is a human reading a channel — the recipient here is the bot itself: it receives the output as an incoming message, acts on anything that needs action, and responds in its chat. Use it when scheduled output should be *processed*, not just posted. + +- `bot-chat` (bare) targets the job's own profile. +- `bot-chat:` targets another profile **on the same machine**. Names are validated against `hermes profile list` when the job is created; profiles on other gateways or machines can never be targeted, so same-named profiles across machines are unambiguous. +- Each delivery costs the target bot one full agent turn — mind the schedule frequency. +- Composes with other targets (`bot-chat,telegram`) but is never included in `all`. + ### Routing intent (`all`) `all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`. @@ -557,6 +568,18 @@ cron: Or set the `HERMES_CRON_MEDIA_SEND_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 300s default. A timed-out attachment is recorded in the job's run status as a partial delivery failure (the text still delivers). +## Bot Chat delivery timeout + +A `bot-chat` delivery runs a full agent turn in the target bot's chat, so its bound is minutes, not seconds — 600s by default: + +```yaml +# ~/.hermes/config.yaml +cron: + bot_chat_delivery_timeout_seconds: 900 +``` + +A timed-out delivery is recorded in `last_delivery_error`; the bot's turn may still complete on its own. + ## No-agent mode (script-only jobs) For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely: