Files
hermes-agent/tools/bot_mode_dm.py
liuhao1024 37243bd668 fix(tools): resolve hermes CLI beside the interpreter in bot_mode_dm deliveries
Bot-to-bot message_agent delivery builds both transport argvs (local
teammate chat and peer dm) with a bare "hermes" as argv[0]. Since #96631
the delivery runner spawns under terminal_tool's isolated host-local
environment, which does not inherit the gateway's PATH — so on
docker/service installs (venv at /opt/hermes/.venv) every delivery exits
with FileNotFoundError: 'hermes'.

Resolve the CLI with bot_relay._hermes_cli() (#93590) — the venv sibling
of this interpreter, then shutil.which, then the bare name — at both
argv construction sites. The turn-lock matcher in _delivery_lock()
already matches argv[0] by basename, so absolute paths lock exactly as
before.

Fixes #100662
2026-09-14 17:04:56 -07:00

664 lines
33 KiB
Python

"""Bot Mode agent-to-agent DM tool — ``message_agent``.
Lets a Bot Mode agent message a teammate (a profile on this install, an agent on
a registered peer gateway, or one on another Desktop-connected machine): the
target is validated against the live roster, the attribution prefix is applied
server-side, and the reply arrives later via the background-process completion
notification (fire-and-forget). Containment: the schema is injected ONLY into a
bot's canonical "Bot Chat" session on a Bot-Mode-managed install (same gate as
``tools/bot_mode_probe.py``; never in the registry or any toolset), and dispatch
re-checks that gate so a forged call returns a structured error. Transports:
local → ``hermes -p <name> chat --in ~ -c "Bot Chat" --create-if-missing -Q
--query-file <tmp>``; peer → ``hermes peer dm <peer>[/<name>] < <tmp>``; both via
``terminal_tool(background=True, notify_on_complete=True)``.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import re
import shlex
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any, Optional
# Top-level imports stay stdlib-only: this module also runs directly as the background
# delivery runner (``python bot_mode_dm.py --run-delivery …``); Hermes helpers import lazily.
logger = logging.getLogger(__name__)
MESSAGE_AGENT_TOOL_NAME = "message_agent"
# Message body cap — generous for real work, small enough that a runaway paste can't
# turn one DM into a context bomb on the recipient.
MESSAGE_MAX_CHARS = 16000
# A runner owns and removes each DM file; this bounds residual plaintext lifetime if
# the machine dies between spawn ack and the runner's finally.
_DM_DIR_NAME = "hermes-dm"
_DM_STALE_SECONDS = 24 * 60 * 60
_LIVE_WAIT_SECONDS = 300
# '<peer>/<agent>' — peer names are lowercase (``hermes peer`` normalizes them).
_PEER_TARGET_RE = re.compile(r"^([a-z0-9][a-z0-9_-]{0,63})/([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})$")
# Same shape as ``tools.bot_relay._HANDLE_RE`` (kept local: see import note above).
_LOCAL_TARGET_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
def _default_home() -> str:
from hermes_constants import get_process_hermes_home
return str(get_process_hermes_home())
def message_agent_tool_schema() -> dict:
"""OpenAI-format schema for ``message_agent`` (injected, not registered)."""
return {
"type": "function",
"function": {
"name": MESSAGE_AGENT_TOOL_NAME,
"description": (
"Send a message to ANOTHER agent (teammate) on this install, or to an "
"agent on a registered peer gateway. This is FIRE-AND-FORGET and "
"asynchronous, like texting: it validates the target against the live "
"roster, delivers your message into that agent's own Bot Chat with your "
"attribution automatically prefixed, and returns immediately with a "
"delivery acknowledgement. It does NOT return their reply and you must "
"not wait or poll for one — send it, finish your turn, and the reply "
"arrives later as a background-process completion notification that "
"wakes you. COMPOSE the message yourself: write what YOU want to say to "
"that agent (lead with the point; include the concrete ask or result). "
"Never paste the user's words verbatim — paraphrase the actionable "
"substance, and keep private 1:1 chat content private. Message one "
"clearly relevant teammate when it genuinely helps the user's goal; "
"don't fan out to several agents unless the user explicitly asked. "
"Use the teammate roster in your system prompt (names + roles) to pick "
"the right recipient; targets: a teammate name (e.g. 'researcher'), "
"'<peer>/<agent>' for an agent on a registered peer gateway "
"(e.g. 'spark/researcher', or just '<peer>' for the peer's main agent), "
"or an agent on another connected machine from your roster (use "
"'<handle>@<connection>' if the same handle exists on several)."
),
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": (
"Who to message: a teammate profile name from your roster "
"('researcher', 'hermes' for the default agent), or "
"'<peer>' / '<peer>/<agent>' for a registered peer gateway."
),
},
"message": {
"type": "string",
"description": (
"The message YOU composed for that agent (max "
f"{MESSAGE_MAX_CHARS} chars). Do not include the "
"'Message from …' prefix — it is added automatically."
),
},
},
"required": ["target", "message"],
},
},
}
def message_agent_authorized(agent: Any) -> bool:
"""The ``message_agent`` gate: a protocol-enabled agent whose session is a managed
Bot-Mode canonical Bot Chat. Session-stable, so it is prompt-cache safe to re-evaluate
on every tool-snapshot rebuild. Never raises."""
try:
if not getattr(agent, "_bot_mode_protocol", True):
return False
from tools.bot_mode_probe import BOT_CHAT_TITLE, is_bot_mode_managed
# Managed-install check, NOT section non-emptiness: a SOUL.md carrying the
# legacy protocol text gets an empty section but must still get the tool.
return _session_title(agent) == BOT_CHAT_TITLE and is_bot_mode_managed(_agent_home(agent))
except Exception: # pragma: no cover — must never break a turn
logger.debug("message_agent_authorized failed", exc_info=True)
return False
def ensure_message_agent_tool(agent: Any) -> bool:
"""Inject the ``message_agent`` schema into a Bot Chat agent's tool list (once per turn).
Idempotent and deterministic for the session's life (the gate is stable from the
first turn), so the tool list is byte-identical across turns — prompt-cache safe. Never raises."""
try:
if not getattr(agent, "_bot_mode_protocol", True):
return False
tools = getattr(agent, "tools", None)
if tools and any(
isinstance(t, dict) and t.get("function", {}).get("name") == MESSAGE_AGENT_TOOL_NAME
for t in tools
):
return True
if not message_agent_authorized(agent):
return False
if agent.tools is None:
agent.tools = []
agent.tools.append(message_agent_tool_schema())
valid = getattr(agent, "valid_tool_names", None)
if isinstance(valid, set):
valid.add(MESSAGE_AGENT_TOOL_NAME)
return True
except Exception: # pragma: no cover — must never break a turn
logger.debug("ensure_message_agent_tool failed", exc_info=True)
return False
def _resolve_local_name(target: str, roster: list[str]) -> Optional[str]:
"""Map a target handle to a profile name ('hermes' → 'default')."""
want = target.strip().lower()
if want == "hermes":
return "default" if "default" in roster else None
return next((name for name in roster if name.lower() == want), None) if want else None
def _err(message: str, *, roster: list[str] | None = None, peers: list[str] | None = None) -> str:
from tools.bot_failure_reasons import classify_agent_error
payload: dict[str, Any] = {"error": message, "reason": classify_agent_error(message)}
if roster is not None:
payload["teammates"] = roster
if peers is not None:
payload["peers"] = peers
return json.dumps(payload)
def message_agent_tool(target: str = "", message: str = "", task_id: Optional[str] = None, agent: Any = None) -> str:
"""Deliver ``message`` to ``target``'s Bot Chat. Returns a JSON ack/error.
``agent`` is the calling AIAgent — used for the Bot Chat gate and sender identity."""
home = _agent_home(agent)
try:
from tools.bot_mode_probe import (
BOT_CHAT_TITLE, _handle, _hermes_root, _peers, _profile_name as _self_profile_name, _roster,
is_bot_mode_managed,
)
from tools.bot_relay import BOT_CHAT_TURN_ARGS, _hermes_cli
if _session_title(agent) != BOT_CHAT_TITLE:
return _err("message_agent is only available in a Bot Mode 'Bot Chat' session. "
"This session is not one; do not retry.")
if not is_bot_mode_managed(home):
return _err("This install is not Bot-Mode-managed (no bot roster); "
"message_agent is unavailable. Do not retry.")
except Exception as exc: # pragma: no cover — defensive
return _err(f"Bot Mode gate check failed: {exc}")
root, me = _hermes_root(Path(home)), _self_profile_name(Path(home))
roster_homes = dict(_roster(root))
roster = list(roster_homes)
peers = _peers(root)
teammates = [_handle(n) for n in roster if n != me]
def _roster_err(msg: str) -> str:
return _err(msg, roster=teammates, peers=peers)
body = str(message or "").strip()
if not body:
return _err("message is required — compose what you want to say to that agent.")
if len(body) > MESSAGE_MAX_CHARS:
return _err(f"message too long ({len(body)} chars > {MESSAGE_MAX_CHARS}). "
"Send the essentials; share large content as a file path instead.")
raw_target = str(target or "").strip().lstrip("@")
if not raw_target:
return _roster_err("target is required.")
content = f"Message from 🤖 {_handle(me)} (@{_handle(me)}): " + body
delivery = dict(task_id=task_id, agent=agent)
# Attribution for the recipient's memory hooks; the text prefix above stays the human-facing signature.
author = {"id": f"bot:{me}", "name": _handle(me), "is_bot": True}
# Peer target: '<peer>/<agent>' or a bare registered peer name.
peer_match = _PEER_TARGET_RE.match(raw_target)
if peer_match or raw_target.lower() in peers:
peer_name, peer_profile = peer_match.groups() if peer_match else (raw_target.lower(), None)
if peer_name not in peers:
return _roster_err(f"No registered peer named '{peer_name}'.")
dm_target = f"{peer_name}/{peer_profile}" if peer_profile else peer_name
# A peer dm crosses installs: qualify the id with this host so the peer's own '<me>' stays distinct.
from agent.turn_author import bot_author_id, local_origin
peer_author = {**author, "id": bot_author_id(me, local_origin())}
# Pin the registry-owning profile: `hermes peer` resolves bot_peers via the profile-scoped
# load_config(), while the roster above reads the machine-root config — the CLI must run
# in that same profile or a secondary-profile bot sees an empty registry.
# The delivery runs in a background service context whose PATH lacks the gateway's
# venv bin dir, so a bare "hermes" resolves to a system install and dies on import
# under the wrong interpreter (#108628). _hermes_cli pins the entrypoint beside
# this interpreter; _delivery_lock/_local_delivery_home match argv[0] by basename,
# so the absolute path stays compatible.
return _start_delivery([_hermes_cli(), "-p", _self_profile_name(root), "peer", "dm", dm_target], content,
f"@{peer_profile or peer_name} on peer '{peer_name}'", stdin_file=True,
author=peer_author, **delivery)
# Local teammate.
is_local_shape = bool(_LOCAL_TARGET_RE.match(raw_target))
if not is_local_shape and "@" not in raw_target:
return _roster_err(f"Invalid target: {raw_target!r}.")
resolved = _resolve_local_name(raw_target, roster) if is_local_shape else None
if resolved is None or resolved == me:
# Unknown locally, or same-name target on ANOTHER connection (this gateway's 'default'
# messaging the cloud 'default'): every Desktop-connected gateway is reachable via the
# relay roster, so try that before reporting a resolution failure / self-message.
relayed = _try_relay_delivery(root, raw_target, content, me, **delivery)
if relayed is not None:
return relayed
if resolved == me:
return _err("You can't message yourself. Pick a teammate from the roster.")
return _roster_err(f"No teammate named '{raw_target}' on this install, on a connected "
"machine, or on a registered peer. Pick a name from the roster "
"(roles are listed in your system prompt).")
return _start_delivery([_hermes_cli(), "-p", resolved, *BOT_CHAT_TURN_ARGS], content, f"@{_handle(resolved)}",
stdin_file=False, profile_home=roster_homes[resolved], author=author, **delivery)
def _try_relay_delivery(root: Path, raw_target: str, content: str, me: str, *,
task_id: Optional[str], agent: Any) -> Optional[str]:
"""Cross-connection delivery via the Desktop relay; None when the target doesn't
resolve against the relay roster. The envelope is queued on disk for the Desktop
to drain; a background waiter is spawned immediately so the relayed reply wakes
the sender through the standard completion-notification path."""
try:
from tools.bot_mode_probe import _handle
from tools.bot_relay import (
EnvelopeRefusedError, enqueue_envelope, read_remote_roster, resolve_remote_target, waiter_command,
)
roster = read_remote_roster(root)
match = resolve_remote_target(raw_target, roster) if roster else None
if match is None:
return None
if match == "ambiguous":
want = raw_target.strip().lstrip("@").lower()
forms = ", ".join(f"{r['handle']}@{r['connection_id']}" for r in roster if r["handle"].lower() == want)
return _err(f"'{raw_target}' exists on several connected machines — disambiguate with one of: {forms}.")
try:
envelope = enqueue_envelope(root, target=match, message=content, sender_profile=me, sender_handle=_handle(me))
except EnvelopeRefusedError as exc:
# Fail fast: target definitively offline — nothing was queued.
# Structured refusal so the agent can distinguish it from a resolution error ('runtime_offline'
# per the #93091 reason enum).
return json.dumps({"error": str(exc), "reason": exc.reason})
label = f"@{match['handle']} on {match['connection_label'] or match['connection_id']}"
return _spawn_delivery(waiter_command(root, envelope), label, task_id=task_id, agent=agent)
except Exception:
logger.debug("relay delivery attempt failed", exc_info=True)
return None
def _dm_dir() -> Path:
uid_getter = getattr(os, "getuid", None)
uid = uid_getter() if callable(uid_getter) else None
path = Path(tempfile.gettempdir()) / (f"{_DM_DIR_NAME}-{uid}" if uid is not None else _DM_DIR_NAME)
path.mkdir(mode=0o700, exist_ok=True)
# Shared POSIX temp roots need a per-user directory. Fail closed if an
# attacker pre-created the expected path or replaced it with a symlink.
info = path.lstat()
if not stat.S_ISDIR(info.st_mode):
raise PermissionError(f"DM temp path is not a directory: {path}")
if uid is not None and info.st_uid != uid:
raise PermissionError(f"DM temp directory is owned by another user: {path}")
if stat.S_IMODE(info.st_mode) != 0o700:
path.chmod(0o700)
return path
def cleanup_bot_dm_cache(max_age_hours: float = _DM_STALE_SECONDS / 3600, *, now: float | None = None) -> int:
"""Delete orphaned DM payload files older than *max_age_hours*; returns count.
Same contract as the other ``cleanup_*_cache`` helpers (hourly gateway housekeeping);
legacy temp-root locations from versions predating the dedicated directory are swept too."""
cutoff = (time.time() if now is None else now) - max_age_hours * 3600
temp_root = Path(tempfile.gettempdir())
locations = [(temp_root, "hermes-dm-*.txt"), (temp_root, "hermes-relay-dm-*.txt")]
with contextlib.suppress(OSError):
locations.append((_dm_dir(), "*.txt"))
from tools.bot_relay import unlink_files_older_than
return sum(unlink_files_older_than(d, pattern, cutoff) for d, pattern in locations)
def _unlink_dm_file(path: str) -> None:
with contextlib.suppress(OSError):
os.unlink(path)
def _write_dm_file(content: str) -> str:
"""The message rides a temp file — never inline shell text."""
cleanup_bot_dm_cache()
fd, path = tempfile.mkstemp(prefix="dm-", suffix=".txt", dir=_dm_dir(), text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
except BaseException:
# If fdopen itself failed the raw descriptor is still ours; closing twice is harmless.
with contextlib.suppress(OSError):
os.close(fd)
_unlink_dm_file(path)
raise
return path
def _delivery_lock(argv: list[str], *, stdin_file: bool):
"""Per-profile turn lock for a LOCAL teammate delivery: local and relay deliveries
into one profile both run a Bot Chat turn here, so the turn window is serialized on
``tools.bot_relay``'s cross-process lock. Peer transports (stdin mode) are locked
on the remote gateway by its own deliver path.
See #93091.
"""
# Match the CLI element by basename: argv[0] may be an absolute venv path
# (service contexts lack PATH) and carries .exe on Windows; split on both separators.
# Split on both separators so the shape matches regardless of which platform built the argv. See #93590.
cli = (argv[0] if argv else "").rsplit("\\", 1)[-1].rsplit("/", 1)[-1]
if stdin_file or len(argv) < 3 or cli not in ("hermes", "hermes.exe") or argv[1] != "-p":
return contextlib.nullcontext()
from tools.bot_mode_probe import _hermes_root
from tools.bot_relay import acquire_turn_lock
return acquire_turn_lock(_hermes_root(Path(_default_home())), argv[2])
def _run_local_turn(argv: list[str], dm_file: str, *, env: Optional[dict[str, str]] = None) -> int:
"""One Bot Chat turn via ``--query-file`` (plus one policy-gated retry); re-emits
the transport's streams and returns its exit code. Transient failures re-run the
same session; a context_overflow re-run lets the retried turn's pre-API compaction
compact the transcript first (no fresh session is ever minted). Auth/quota/config never retry."""
def _turn():
return subprocess.run([*argv, "--query-file", dm_file], check=False, stdin=subprocess.DEVNULL,
capture_output=True, text=True, env=env)
proc = _turn()
if proc.returncode != 0:
from tools.bot_failure_reasons import RETRY_NONE, classify_agent_error, retry_action
if retry_action(classify_agent_error((proc.stderr or proc.stdout or "").strip()[-500:])) != RETRY_NONE:
proc = _turn()
stderr_text = proc.stderr or ""
reason = next((line.removeprefix("hermes-refusal-reason: ").strip()
for line in stderr_text.splitlines()
if line.startswith("hermes-refusal-reason: ")), None)
# A code wins over prose, including unknown codes from newer CLIs.
# Only older CLIs without a marker need the historical wording fallback.
refused_not_owned = (reason == "SESSION_NOT_OWNED" if reason is not None
else "already has a live owner" in stderr_text)
if proc.returncode != 0 and refused_not_owned:
# The target's Bot Chat is held live by another surface (Desktop); the turn
# never ran — tell the sender plainly instead of leaking a raw lease error.
# See #100523.
who = argv[argv.index("-p") + 1] if "-p" in argv[:-1] else "the teammate"
print(json.dumps({
"error": f"Delivery failed: @{who}'s Bot Chat is open on another "
"surface right now, so your message was NOT delivered. Try again later.",
"reason": "target_busy",
}))
return 1
# Re-emit the transport's streams: stdout is the reply text the
# completion notification carries back to the sending agent.
for stream, text in ((sys.stdout, proc.stdout), (sys.stderr, proc.stderr)):
if text:
stream.write(text)
stream.flush()
return proc.returncode
def _admit_live_dm(profile_home: Path | None, dm_file: str, author: Optional[dict] = None) -> dict | None:
"""Pin intent before admission; retries may inspect, never change transport."""
from tools.bot_live_delivery import deliver_to_live_owner, find_canonical_live_owner, read_delivery_result
from utils import fsync_directory
intent: dict[str, Any]
intent_path = Path(dm_file + ".live.json")
if intent_path.exists():
intent = json.loads(intent_path.read_text(encoding="utf-8"))
else:
assert profile_home is not None
owner = find_canonical_live_owner(profile_home)
if owner is None:
return None
intent = dict(owner=owner, message=Path(dm_file).read_text(encoding="utf-8"),
delivery_id=hashlib.sha256(str(Path(dm_file).resolve()).encode()).hexdigest(),
**({"author": author} if author else {}))
try:
fd = os.open(intent_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
intent = json.loads(intent_path.read_text(encoding="utf-8"))
else:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(intent, stream)
stream.flush()
os.fsync(stream.fileno())
fsync_directory(intent_path.parent)
home = intent["owner"]["profile_home"]
record = read_delivery_result(home, intent["delivery_id"])
if record is None:
record = deliver_to_live_owner(home, intent["owner"], intent["message"],
delivery_id=intent["delivery_id"], author=intent.get("author"))
return record
def _wait_live_dm(home: str, delivery_id: str) -> int:
from tools.bot_live_delivery import read_delivery_result
deadline = time.monotonic() + _LIVE_WAIT_SECONDS
while True:
record = read_delivery_result(home, delivery_id)
status = record["status"] if record else "ambiguous"
if status not in ("queued", "claimed") or time.monotonic() >= deadline:
break
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
payload = {key: record[key] for key in ("reply", "error", "reason") if record and record.get(key)}
payload.update(status=status, delivery_id=delivery_id)
if status in ("queued", "claimed", "ambiguous"):
payload["detail"] = "Delivery remains pending or its outcome is unknown. Do not resend; receipt is retained."
print(json.dumps(payload))
return 0 if status in ("settled", "queued", "claimed") else 1
def _local_delivery_home(argv: list[str]) -> Path | None:
cli = (argv[0] if argv else "").rsplit("\\", 1)[-1].rsplit("/", 1)[-1]
if len(argv) < 3 or cli not in ("hermes", "hermes.exe") or argv[1] != "-p":
return None
from tools.bot_mode_probe import _hermes_root, _roster
return dict(_roster(_hermes_root(Path(_default_home())))).get(argv[2])
def _run_delivery(argv: list[str], dm_file: str, *, stdin_file: bool,
profile_home: Path | None = None, author: Optional[dict] = None) -> int:
"""Route to the live owner before attempting a CLI transport. Live deliveries
retain their intent/payload and immutable receipt; only CLI/peer payloads are
removed after consumption. The CLI turn window holds the profile lock, so two
deliveries into one profile queue; a bounded wait ends in a 'target_busy' refusal.
``author`` rides to the child as HERMES_TURN_AUTHOR; ``hermes peer dm`` forwards it in the request body.
Local (query-file) turns get one policy-gated retry (#93091 item 5): transient failures re-run the same
session; a context_overflow re-run lets the retried turn's pre-API compaction pass compact the Bot Chat
transcript first (agent/conversation_loop.py) — the sanctioned compression lever; no fresh session is
ever minted. Auth/quota/config failures never retry. Peer transports (stdin mode) retry on their own
gateway's deliver path, not here.
"""
# The live consumer owns turn admission; never compete for its CLI lease.
if not stdin_file:
home = profile_home or _local_delivery_home(argv)
if home is not None or Path(dm_file + ".live.json").exists():
try:
record = _admit_live_dm(home, dm_file, author)
except Exception as exc:
print(json.dumps({"status": "ambiguous", "delivery_id": hashlib.sha256(
str(Path(dm_file).resolve()).encode()).hexdigest(),
"error": f"Live admission outcome unknown: {exc}. Do not resend.",
"evidence_file": dm_file}))
return 1
if record is not None:
return _wait_live_dm(record["profile_home"], record["delivery_id"])
try:
from tools.bot_relay import delivery_env
env = delivery_env(author)
with _delivery_lock(argv, stdin_file=stdin_file):
if not stdin_file:
return _run_local_turn(argv, dm_file, env=env)
# Keep the file open until the transport exits; cleanup occurs
# after subprocess.run returns, not merely after stdin reaches EOF.
with open(dm_file, "r", encoding="utf-8") as stream:
return subprocess.run(argv, stdin=stream, check=False, env=env).returncode
finally:
_unlink_dm_file(dm_file)
def _delivery_command(argv: list[str], dm_file: str, *, stdin_file: bool,
profile_home: Path | None = None, author: Optional[dict] = None) -> str:
"""Build an argv-safe command for the cleanup-owning background runner:
``--run-delivery [--author <json>] <mode> <dm_file> [--profile-home <path>] <argv...>``."""
runner_argv = [sys.executable, str(Path(__file__).resolve()), "--run-delivery",
"stdin" if stdin_file else "query-file", dm_file]
if profile_home is not None:
runner_argv.extend(["--profile-home", str(Path(profile_home).resolve())])
runner_argv.extend(argv)
if sys.platform == "win32":
# The tracked local backend uses Git Bash on native Windows: forward slashes keep drive
# paths executable there; backslash paths are parsed as command names (exit 127).
runner_argv = [part.replace("\\", "/") for part in runner_argv]
if author:
# Inserted after the slash rewrite: JSON escapes are backslashes too.
runner_argv[3:3] = ["--author", json.dumps(author, separators=(",", ":"))]
return shlex.join(runner_argv)
def _start_delivery(argv: list[str], content: str, label: str, *, stdin_file: bool,
task_id: Optional[str], agent: Any, profile_home: Path | None = None,
author: Optional[dict] = None) -> str:
"""Create a DM file and transfer its cleanup ownership to the runner."""
dm_file = _write_dm_file(content)
if profile_home is not None:
try:
record = _admit_live_dm(profile_home, dm_file, author)
except Exception as exc:
return json.dumps({"status": "ambiguous", "delivery_id": hashlib.sha256(
str(Path(dm_file).resolve()).encode()).hexdigest(),
"error": f"Live delivery admission could not be confirmed: {exc}. Do not resend.",
"evidence_file": dm_file})
if record is not None:
command = _delivery_command(argv, dm_file, stdin_file=False, profile_home=profile_home, author=author)
notification = json.loads(_spawn_delivery(command, label, task_id=task_id, agent=agent))
result = dict(status=record["status"], delivery_id=record["delivery_id"], to=label,
detail="Durably queued for the live Bot Chat owner. Do NOT wait or resend; finish your turn.")
if notification.get("error"):
result["notification_error"] = notification["error"]
elif notification.get("process_id"):
result["process_id"] = notification["process_id"]
return json.dumps(result)
try:
command = _delivery_command(argv, dm_file, stdin_file=stdin_file, profile_home=profile_home, author=author)
except BaseException:
_unlink_dm_file(dm_file)
raise
return _spawn_delivery(command, label, dm_file=dm_file, task_id=task_id, agent=agent)
def _spawn_delivery(command: str, label: str, *, dm_file: Optional[str] = None,
task_id: Optional[str], agent: Any) -> str:
"""Launch the cleanup-owning runner and transfer file ownership on ack. ``dm_file``
is None for relay deliveries (the waiter watches a reply file; envelope artifacts
are owned/swept by ``tools/bot_relay.py``)."""
transferred = False
try:
from tools.terminal_tool import terminal_tool
raw = terminal_tool(command, background=True, notify_on_complete=True, task_id=task_id,
workdir=str(Path(__file__).resolve().parent.parent), _host_local=True)
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
parsed = {}
proc_id = parsed.get("session_id") or ""
if parsed.get("error"):
return _err(f"Delivery to {label} failed to start: {parsed['error']}")
if not proc_id:
return _err(f"Delivery to {label} failed to start: no process id returned")
# From here the background runner owns the file (removed after the consumer finishes).
transferred = True
return json.dumps({
"status": "sent",
"to": label,
"detail": (f"Message dispatched to {label}. This is asynchronous — do NOT wait "
"or poll. Finish your turn now; when the delivery completes, its "
"notification carries the reply — relay it then, attributed to that agent."),
"process_id": proc_id,
"sent_at": int(time.time()),
})
except Exception as exc:
logger.error("message_agent delivery spawn failed: %s", exc, exc_info=True)
return _err(f"Delivery to {label} could not be started: {exc}")
finally:
if dm_file and not transferred:
_unlink_dm_file(dm_file)
def _delivery_main(args: list[str]) -> int:
"""Runner entry for the argv ``_delivery_command`` builds. Malformed argv exits 2 without touching the DM file."""
if not args or args[0] != "--run-delivery":
return 2
rest, author = args[1:], None
if rest[:1] == ["--author"]:
from agent.turn_author import parse_turn_author
author = parse_turn_author(rest[1]) if len(rest) > 1 else None
if author is None:
return 2
rest = rest[2:]
if len(rest) < 2 or rest[0] not in ("stdin", "query-file"):
return 2
try:
argv, profile_home = rest[2:], None
if len(argv) >= 2 and argv[0] == "--profile-home":
profile_home, argv = Path(argv[1]), argv[2:]
return _run_delivery(argv, rest[1], stdin_file=rest[0] == "stdin", profile_home=profile_home, author=author)
except Exception as exc:
# 'target_busy': the queued delivery gave up after its bounded wait — surface the
# structured payload on stdout so the completion notification carries it back.
if getattr(exc, "reason", "") == "target_busy":
# See #93091.
print(json.dumps({"error": str(exc), "reason": "target_busy"}))
else:
print(f"message_agent delivery failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
# agent-context helpers (mirror system_prompt.py's resolution)
def _agent_home(agent: Any) -> str:
"""The calling agent's OWN home (session-db derived), not ambient env."""
with contextlib.suppress(Exception):
db_path = getattr(getattr(agent, "_session_db", None), "db_path", None)
if db_path:
return str(Path(db_path).parent)
return _default_home()
def _session_title(agent: Any) -> str:
title = str(getattr(agent, "_session_title_hint", "") or "").strip()
if title:
return title
with contextlib.suppress(Exception):
sdb, sid = getattr(agent, "_session_db", None), getattr(agent, "session_id", None)
if sdb and sid:
return str(sdb.get_session_title(sid) or "").strip()
return ""
if __name__ == "__main__": # pragma: no cover - exercised as a background process
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
raise SystemExit(_delivery_main(sys.argv[1:]))