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:
@@ -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'\)/)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user