Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/desktop/src/plugins/hermes-bots/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -8945,6 +8950,7 @@ function CreateRoutineDialog({ bot, open, onClose }) {
setInstruction('')
setSched(defaultScheduleState())
setContinuity(false)
setTarget('history')
setBusy(false)
setError(null)
}
Expand Down Expand Up @@ -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` })
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 223 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions hermes_cli/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/subcommands/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading