feat(cron): bot-chat delivery target — cron output lands in a bot's canonical Bot Chat and the bot responds

deliver='bot-chat[:<profile>]' 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 / <bot>'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.
This commit is contained in:
Teknium
2026-08-21 04:38:21 -07:00
parent b102999d80
commit a2da0ab797
11 changed files with 609 additions and 3 deletions
+18 -1
View File
@@ -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: [
@@ -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'\)/)
})
+223
View File
@@ -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[:<profile>] — 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 <profile>] chat --in ~ -c "Bot Chat" --create-if-missing
-Q --query-file <tmp>`` — 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:<name>``
# 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[:<name>]`` 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
+19
View File
@@ -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
# ---------------------------------------------------------------------------
+5 -1
View File
@@ -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(
+216
View File
@@ -0,0 +1,216 @@
"""Bot Chat cron delivery: deliver='bot-chat[:<profile>]' 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 <tmp>."""
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)
+44 -1
View File
@@ -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[:<profile>]`` 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:<profile>' 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",
+5
View File
@@ -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),
)
),
)
@@ -254,6 +254,7 @@ Most platforms also accept an optional thread/topic as a third segment: `platfor
| WeCom | `wecom` or `wecom:<chat_id>` | Bare name delivers to WeCom |
| BlueBubbles | `bluebubbles` or `bluebubbles:<chat_guid>` | Bare name delivers to iMessage via BlueBubbles |
| QQ Bot | `qqbot` or `qqbot:<chat_id>` | Bare name delivers to QQ (Tencent) via Official API v2 |
| Bot Chat | `bot-chat` or `bot-chat:<profile>` | 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:<chat_id>` 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:<chat_id>:<thread_id>` (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:<profile>`) is a machine-local pseudo-platform, not a gateway adapter: the scheduler delivers by running `hermes [-p <profile>] chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file <tmp>` — 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:
+22
View File
@@ -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.
---
+23
View File
@@ -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:<profile>` 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: