Files
Edwin He 9e5b8741d2 feat(cli): route host-scoped requests to the replica holding the host's tunnel (#4185)
Adds a per-host routing key (the ``X-Databricks-Omnigent-Slice-Key`` header)
so that, on a horizontally-scaled multi-tenant deployment, every request
scoped to a given host or session lands on the replica that holds that host's
runner tunnel: the host's control tunnel, its runners' tunnels, and all of a
session's turn/resource/stream traffic converge on one replica when they carry
the same key (the host_id). On an unsharded / single-replica deployment the key
is never emitted, so this is a no-op there.

Client-side only. The key is built centrally in
``cli_auth.databricks_request_headers`` (gated on the workspace-hosted mount)
and threaded through the one factory ``open_server_client`` plus
``_remote_headers`` / ``open_daemon_client``. Callers pass a host_id when they
have one; runner-side callers (forwarders, permission checks) inherit it
automatically from the ``OMNIGENT_RUNNER_SLICE_KEY`` env var the host stamps at
runner launch, so no per-callsite change is needed there. The WebSocket attach
handshake and its reconnects carry the same key.

``chat._remote_headers`` gains a ``host_id`` keyword (defaulting to ``None`` so
probes and health checks are unaffected). ``_DatabricksTokenAuth`` resolves the
session's host per request from the session→host map and can be repointed via
``pin_session`` when a client outlives its session (e.g. a ``--fork`` in the
REPL lands under a new conversation id on a new host). Session-host state is
always written on attach — clearing a stale mapping when the server reports no
host matters as much as setting one.

A ``tests/cli`` conftest fixture isolates the runner machine's own host
identity so "no slice key on this call" assertions are hermetic regardless of
whether the box running the suite is itself a host.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-12 21:06:44 -07:00

1525 lines
70 KiB
Python

"""TUI→web forwarder for the hermes-native harness.
The ``omnigent hermes`` wrapper launches the real ``hermes`` TUI in a runner-owned
tmux pane, and :mod:`omnigent.hermes_native_bridge` injects web-UI messages into
it. That covers the web→TUI direction, but the *embedded terminal* is then the
only surface that reflects the agent's work — the Omnigent conversation view (chat
bubbles, title) stays empty because nothing mirrors the TUI's transcript back into
the session.
This module is that missing mirror — the Hermes analog of
:mod:`omnigent.goose_native_forwarder`. Hermes stores all sessions in a single
SQLite database at ``$HERMES_HOME/state.db`` (default ``~/.hermes/state.db``,
verified against the hermes-agent ``hermes_state.py`` schema): a ``sessions`` row
per session (``id`` TEXT, ``source``, ``cwd``, ``started_at`` REAL-seconds) and a
``messages`` row per turn (``id`` autoincrement, ``session_id`` FK, ``role``,
``content`` TEXT, ``active``).
Unlike goose-native, Hermes auto-generates its ``sessions.id`` and gives no
``--name`` to pin it, so discovery follows cursor-native instead: bind the newest
session whose ``cwd`` matches this terminal's workspace and whose ``started_at`` is
at/after the recorded launch time, with a claim guard so two hermes-native sessions
launched in the same cwd never mirror the same row into two conversations. We then
poll ``messages`` past a high-water ``id`` and POST new user/assistant rows as
``external_conversation_item`` events (which also seeds the session title).
To make the web render this harness's in-flight tool calls **live** (a spinner +
ticking elapsed timer, matching claude-/codex-native), the forwarder assigns one
``response_id`` per turn — ``hermes_turn_{opening-msg-id}`` shared across every row
of the turn — POSTs a ``running`` ``external_session_status`` edge carrying that id
at turn start, and stamps the turn's mirrored ``function_call`` items with the same
id (see :func:`_annotate_turn_actions`). The server keys the live card off a
``running`` edge whose ``response_id`` matches the items' ``response_id`` (#1874).
The forwarder deliberately does NOT take ``idle`` ownership: the runner's
PTY-activity watcher (see :mod:`omnigent.runner.app`) still emits the id-less
``running``/``idle`` ``session.status`` edges for hermes-native (as for
goose-/cursor-native), and the server pops the active response id on *any* ``idle``.
A silent tool (e.g. ``sleep``) leaves the pane quiet, so that watcher's ~1s idle
would settle a live card mid-turn — the forwarder therefore re-asserts the in-flight
turn's ``running`` each poll. The trade-off: an aborted turn whose terminal row is
never written is indistinguishable from a silent tool in the store, so its card
stays live until a terminal row lands (an interrupt's empty-prose assistant row
closes the turn) or the next user turn re-opens with a fresh id; the watcher's idle
settles the card only once nothing re-arms the id (turn closed, or this forwarder
died). That watcher drives only the web spinner, though — it never wakes a parent
orchestrator. So this forwarder additionally derives turn completion from the
message log (an ``assistant`` row with no ``tool_calls`` is the agentic loop's
terminal step) and POSTs an ``external_session_status: idle`` event once per
completed turn — the SAME server contract claude-/codex-/opencode-/cursor-native
use to mark a sub-agent turn terminal and wake its parent's inbox. The post is
deduped against a persisted posted-count (:mod:`omnigent.hermes_native_status`) so
a supervisor restart never re-wakes the parent for a turn it already reported.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import re
import sqlite3
import time
from dataclasses import dataclass
from itertools import groupby
from pathlib import Path
import httpx
from omnigent import hermes_native_status
from omnigent.inner.native_attachments import ATTACHMENT_MARKER_STRIP_PATTERN
_logger = logging.getLogger(__name__)
#: Seconds between store polls. Hermes flushes a ``messages`` row per agentic step
#: (each assistant-text / tool-call cycle) as a turn progresses, so a snappier
#: sub-second cadence makes the mirrored chat track the terminal step-by-step.
#: 0.4s balances liveness vs. load.
_DEFAULT_POLL_INTERVAL_S = 0.4
_POST_TIMEOUT_S = 30.0
# Supervisor backoff (mirrors goose_native_forwarder.supervise_goose_forwarder).
_SUPERVISOR_INITIAL_BACKOFF_S = 1.0
_SUPERVISOR_MAX_BACKOFF_S = 30.0
_SUPERVISOR_HEALTHY_UPTIME_S = 60.0
#: Discovery tolerance (seconds): a session whose ``started_at`` is within this
#: many seconds *before* the recorded launch time still counts as this session's
#: row. Covers the small skew between the runner stamping ``launch_epoch_s`` and
#: Hermes writing the ``sessions`` row once the TUI initializes.
_DISCOVERY_SKEW_S = 10.0
_STATE_FILE = "hermes_forwarder.json"
#: Event type for a one-shot reasoning (thinking) mirror, matching the
#: codex-/opencode-native forwarders' transient reasoning contract.
_EXTERNAL_OUTPUT_REASONING_DELTA = "external_output_reasoning_delta"
# A sibling session's persisted claim (naming the same ``hermes_session_id``)
# counts as a LIVE owner only if its heartbeat was refreshed within this window;
# an older claim is treated as a dead session and may be taken over. Generous
# relative to the ~0.4s poll so a brief supervisor backoff never drops a claim.
_CLAIM_FRESH_MS = 30_000
# Sqlite read errors are swallowed in the helpers below (a live DB is briefly
# unreadable mid-checkpoint, so returning empty and retrying is correct). But a
# *persistent* error (schema drift, wrong path) would otherwise leave the chat
# view silently empty forever — so surface each distinct error string once.
_warned_sqlite_errors: set[str] = set()
def _warn_sqlite_once(context: str, exc: sqlite3.Error) -> None:
"""Log a distinct sqlite error at warning level once (dedup by message)."""
key = f"{context}:{exc}"
if key in _warned_sqlite_errors:
return
_warned_sqlite_errors.add(key)
_logger.warning("hermes forwarder sqlite error during %s: %s", context, exc)
# The executor injects ``[Attached: <path>]`` (or the could-not-load marker
# from native_attachments) for web-UI attachments before pasting into the TUI;
# strip them from the mirrored bubble (internal bridge details).
_ATTACHMENT_MARKER_RE = re.compile(ATTACHMENT_MARKER_STRIP_PATTERN)
# Hermes injects skill content as a user message prefixed with this marker.
# The full skill prompt is not useful in the web UI — replace it with a
# short summary so the chat view stays clean.
_SKILL_INVOKE_RE = re.compile(
r'^\[IMPORTANT: The user has invoked the "(?P<name>[^"]+)" skill',
)
#: Maximum characters for a tool output mirrored into the web UI chat view.
#: Longer outputs are truncated so skill loads and other verbose results don't
#: flood the conversation bubbles. The full output remains visible in the
def _read_model_from_hermes_config(bridge_dir: Path) -> str | None:
"""Best-effort read of the model name from the per-session HERMES_HOME config.
Falls back to the user's ``~/.hermes/config.yaml`` if no per-session config
exists. Returns ``None`` when the model cannot be determined.
"""
candidates = [
bridge_dir / "hermes_home" / "config.yaml",
Path.home() / ".hermes" / "config.yaml",
]
for config_path in candidates:
if not config_path.is_file():
continue
try:
import yaml
data = yaml.safe_load(config_path.read_text()) or {}
model = data.get("model")
if isinstance(model, str) and model:
return model
except Exception: # noqa: BLE001
continue
return None
class _HermesUsageTracker:
"""Post ``external_session_usage`` events for a hermes-native session.
Hermes' SQLite ``state.db`` does not expose per-message token counts, so
this tracker posts only the model name to the server — enough for the
server to associate the model for display and (eventually) pricing.
Follows the :class:`omnigent.codex_native_forwarder._SessionUsageCoalescer`
pattern: deduplicates (only posts when the model changes) and is flushed
from the poll loop.
TODO: Token-level cost tracking (input_tokens, output_tokens, total_tokens)
requires Hermes to expose usage data in its state.db or session transcript.
"""
def __init__(
self,
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
) -> None:
self._client = client
self._session_id = session_id
self._bridge_dir = bridge_dir
self._model: str | None = None
self._posted_model: str | None = None
async def flush(self) -> None:
"""Post the model name if it changed since the last flush."""
if self._model is None:
self._model = await asyncio.to_thread(_read_model_from_hermes_config, self._bridge_dir)
if not self._model or self._model == self._posted_model:
return
try:
resp = await self._client.post(
f"/v1/sessions/{self._session_id}/events",
json={
"type": "external_session_usage",
"data": {"model": self._model},
},
)
if resp.status_code < 400:
self._posted_model = self._model
else:
_logger.warning("hermes usage tracker POST failed: status=%s", resp.status_code)
except httpx.HTTPError:
_logger.debug("hermes usage tracker POST failed", exc_info=True)
def _hermes_home() -> Path:
"""Return Hermes' home dir for this process (``$HERMES_HOME`` or ``~/.hermes``)."""
raw = os.environ.get("HERMES_HOME", "").strip()
return Path(raw) if raw else Path.home() / ".hermes"
def default_state_db() -> Path:
"""Return Hermes' SQLite session store path for this process.
Resolves to ``$HERMES_HOME/state.db`` (default ``~/.hermes/state.db``) the same
way Hermes' own ``get_hermes_home()`` does, so the forwarder reads the exact
DB the native TUI writes. Overridable via ``HERMES_STATE_DB`` (tests,
non-standard installs).
"""
override = os.environ.get("HERMES_STATE_DB", "").strip()
if override:
return Path(override)
return _hermes_home() / "state.db"
@dataclass
class _ForwardState:
"""Durable forwarder cursor, persisted to ``bridge_dir/hermes_forwarder.json``.
:param hermes_session_id: The resolved Hermes ``sessions.id`` being tailed, or
``None`` before one is discovered.
:param last_id: Highest **fully** processed ``messages.id``: every item the row
expanded to was forwarded, or the row was skipped. ``messages.id`` is
autoincrement, so the high-water mark is sufficient dedup with O(1) state.
:param partial_row_id: The ``messages.id`` of a row whose mirroring failed
partway, or ``0`` when none is pending. One row expands to several items
(reasoning, prose, a call per tool call), so ``last_id`` cannot advance until
the last of them lands, or the row is skipped next poll and its undelivered
items are lost. Named explicitly (rather than implied as "the row after
``last_id``") because compaction can soft-delete the row before the retry,
in which case the offset must not be applied to some other row.
:param partial_row_items: How many of *partial_row_id*'s items already posted.
The row is re-read whole and this many leading items are dropped rather than
posted twice.
:param launch_epoch_s: This session's launch time (Unix seconds), used to
scope discovery and to break ties when two sessions discover the same row:
the earlier-launched (established) session keeps it. ``0.0`` for cold.
:param heartbeat_ms: Wall-clock ms of the last persist. A sibling reads this
to tell a live owner from a dead session's leftover claim. Stamped by
:func:`_write_state`.
:param active_turn_id: The per-turn ``response_id`` of the turn currently in
flight (``hermes_turn_{opening-msg-id}``), or ``None`` between turns.
Persisted so a turn that spans polls — or a forwarder restart mid-turn —
keeps its id and does not re-emit a ``running`` edge (see
:func:`_annotate_turn_actions`).
"""
hermes_session_id: str | None = None
last_id: int = 0
partial_row_id: int = 0
partial_row_items: int = 0
launch_epoch_s: float = 0.0
heartbeat_ms: int = 0
active_turn_id: str | None = None
def _read_state(bridge_dir: Path) -> _ForwardState:
"""Load the persisted forward cursor, or a cold default."""
try:
raw = (bridge_dir / _STATE_FILE).read_text(encoding="utf-8")
data = json.loads(raw)
except (OSError, ValueError):
return _ForwardState()
sid = data.get("hermes_session_id")
last_id = data.get("last_id")
partial_id = data.get("partial_row_id")
partial_items = data.get("partial_row_items")
# Both or neither: a partial row id without a positive item count (or vice
# versa) is meaningless, and honoring half of it would drop or duplicate items.
if not (
isinstance(partial_id, int)
and partial_id > 0
and isinstance(partial_items, int)
and partial_items > 0
):
partial_id, partial_items = 0, 0
launch_epoch_s = data.get("launch_epoch_s")
heartbeat_ms = data.get("heartbeat_ms")
active_turn_id = data.get("active_turn_id")
return _ForwardState(
hermes_session_id=sid if isinstance(sid, str) else None,
last_id=last_id if isinstance(last_id, int) else 0,
partial_row_id=partial_id,
partial_row_items=partial_items,
launch_epoch_s=float(launch_epoch_s) if isinstance(launch_epoch_s, (int, float)) else 0.0,
heartbeat_ms=heartbeat_ms if isinstance(heartbeat_ms, int) else 0,
active_turn_id=active_turn_id
if isinstance(active_turn_id, str) and active_turn_id
else None,
)
def _write_state(bridge_dir: Path, state: _ForwardState) -> bool:
"""Atomically persist the forward cursor (tmp write + rename).
:returns: ``True`` on success. A failure is logged and returns ``False`` — the
in-memory cursor still guards against within-process re-posting.
"""
try:
bridge_dir.mkdir(parents=True, exist_ok=True)
tmp = bridge_dir / (_STATE_FILE + ".tmp")
tmp.write_text(
json.dumps(
{
"hermes_session_id": state.hermes_session_id,
"last_id": state.last_id,
"partial_row_id": state.partial_row_id,
"partial_row_items": state.partial_row_items,
"launch_epoch_s": state.launch_epoch_s,
"active_turn_id": state.active_turn_id,
# Stamp the heartbeat at persist time so every poll refreshes
# the session claim; a peer treats a claim older than
# ``_CLAIM_FRESH_MS`` as a dead session it may take over.
"heartbeat_ms": int(time.time() * 1000),
}
),
encoding="utf-8",
)
os.replace(tmp, bridge_dir / _STATE_FILE)
return True
except OSError:
_logger.warning(
"hermes forwarder could not persist state to %s", bridge_dir, exc_info=True
)
return False
def clear_hermes_bridge_state(bridge_dir: Path) -> None:
"""Remove the persisted forward cursor so a re-created terminal starts clean."""
with contextlib.suppress(OSError):
(bridge_dir / _STATE_FILE).unlink()
def _session_claimed_by_other(
bridge_dir: Path, hermes_session_id: str, my_launch_s: float
) -> bool:
"""Whether another LIVE session is already mirroring *hermes_session_id*.
Two hermes-native sessions launched in the same cwd can momentarily discover
the same newest ``sessions`` row before each binds its own — without this
guard both would mirror it into two conversations. A sibling bridge dir under
the same root claims the row when its persisted state names the same
``hermes_session_id`` with a heartbeat fresher than ``_CLAIM_FRESH_MS``. Ties
resolve toward the EARLIER-launched session (then the lexicographically smaller
bridge-dir name) for a deterministic, symmetric verdict.
:param bridge_dir: This session's bridge dir (its parent is the shared root).
:param hermes_session_id: The Hermes session id this session would mirror.
:param my_launch_s: This session's ``launch_epoch_s``.
:returns: ``True`` if a different live session owns the row.
"""
root = bridge_dir.parent
if not root.is_dir():
return False
now_ms = int(time.time() * 1000)
me = bridge_dir.name
for sibling in root.iterdir():
if sibling.name == me or not sibling.is_dir():
continue
other = _read_state(sibling)
if other.hermes_session_id != hermes_session_id:
continue
if now_ms - other.heartbeat_ms > _CLAIM_FRESH_MS:
continue # stale claim — the owning session is gone; ignore it
if other.launch_epoch_s < my_launch_s:
return True
if other.launch_epoch_s == my_launch_s and sibling.name < me:
return True
return False
def _connect_ro(db_path: Path) -> sqlite3.Connection | None:
"""Open *db_path* read-only in a way that reads the live WAL, or ``None``.
``mode=ro`` (not ``immutable=1``) so a live session's ``-wal`` sidecar is read
via the ``-shm``; a plain connection is the fallback for the rare window where
``-shm`` is momentarily absent. Only SELECTs are issued.
"""
for uri, use_uri in ((f"file:{db_path}?mode=ro", True), (str(db_path), False)):
try:
if use_uri:
return sqlite3.connect(uri, timeout=5.0, uri=True)
return sqlite3.connect(uri, timeout=5.0)
except sqlite3.Error:
continue
return None
def _table_columns(con: sqlite3.Connection, table: str) -> frozenset[str]:
"""Return the set of column names on *table*, or empty on any error.
Hermes' ``state.db`` schema drifts across versions (e.g. schema_version 11
dropped ``sessions.cwd`` and ``messages.active`` / ``messages.compacted``
that older builds carried). The forwarder introspects the live schema and
adapts its SELECTs instead of assuming a fixed column set — otherwise a
single ``no such column`` error aborts discovery / mirroring and the chat
goes silent even though Hermes itself is working (visible in the terminal).
"""
try:
return frozenset(str(r[1]) for r in con.execute(f"PRAGMA table_info({table})"))
except sqlite3.Error:
return frozenset()
def _discover_session_id(
db_path: Path,
workspace: str,
launch_epoch_s: float,
*,
excluded: frozenset[str] = frozenset(),
) -> str | None:
"""Return this terminal's Hermes ``sessions.id``, or ``None`` if not yet present.
Hermes can't be told its session id in advance, so we bind the newest session
created at/after this terminal's launch (minus a small skew). A row whose
``cwd`` matches the terminal's workspace wins outright (the reliable case); if
none match cwd we fall back to the newest qualifying row only when EXACTLY ONE
qualifies — never guessing among multiple, so a concurrent session in another
workspace can't be mirrored by mistake. Rows in *excluded* (already claimed by
a live sibling) are skipped.
:param db_path: The Hermes ``state.db`` to read.
:param workspace: The terminal's working directory (realpath-normalized).
:param launch_epoch_s: Wall-clock seconds when this terminal launched.
:param excluded: Hermes session ids already claimed by a live sibling.
:returns: The matching ``sessions.id``, or ``None``.
"""
con = _connect_ro(db_path)
if con is None:
return None
floor_s = launch_epoch_s - _DISCOVERY_SKEW_S
# Newer Hermes (schema_version >= 11) no longer records ``sessions.cwd``.
# Select it only when present; otherwise treat every row as cwd-less and
# rely on the started_at-since-launch fallback below (the newest lone
# session started after this terminal launched is this terminal's session).
has_cwd = "cwd" in _table_columns(con, "sessions")
try:
if has_cwd:
rows = con.execute(
"SELECT id, cwd FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
(floor_s,),
).fetchall()
else:
rows = [
(sid, None)
for (sid,) in con.execute(
"SELECT id FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
(floor_s,),
).fetchall()
]
except sqlite3.Error as exc:
_warn_sqlite_once("session discovery", exc)
return None
finally:
con.close()
candidates = [
(sid, cwd) for sid, cwd in rows if isinstance(sid, str) and sid and sid not in excluded
]
# Reliable case: a row whose cwd matches the workspace. Newest (rows are
# already started_at DESC) wins.
for sid, cwd in candidates:
if isinstance(cwd, str) and cwd and _same_path(cwd, workspace):
return sid
# Fallback ONLY when Hermes recorded no cwd at all for any candidate (older
# builds / unusual backends): bind a lone candidate. We never bind a row whose
# cwd is a *different* real dir — unlike cursor's md5-hashed dirs, Hermes
# stores the plain path, so a cwd mismatch is a genuine "not my session".
if all(not (isinstance(cwd, str) and cwd) for _sid, cwd in candidates):
if len(candidates) == 1:
return candidates[0][0]
return None
def _discover_child_session(db_path: Path, parent_session_id: str) -> str | None:
"""Return the newest Hermes session whose parent is *parent_session_id*.
Hermes auto-compresses by ending the current session and forking a CHILD
(``sessions.parent_session_id`` points at the old id; present in hermes
v0.17.0 state.db). The forwarder pins one id for life, so after compaction it
keeps polling the dead parent and the chat goes silent. Re-discover the
newest child so the mirror re-pins. Mirrors :func:`_discover_session_id`'s
read-only connect + swallow-and-warn handling; returns ``None`` on any error.
"""
con = _connect_ro(db_path)
if con is None:
return None
try:
row = con.execute(
"SELECT id FROM sessions WHERE parent_session_id = ? ORDER BY started_at DESC LIMIT 1",
(parent_session_id,),
).fetchone()
except sqlite3.Error as exc:
_warn_sqlite_once("child discovery", exc)
return None
finally:
con.close()
if row and isinstance(row[0], str) and row[0]:
return row[0]
return None
def _same_path(a: str, b: str) -> bool:
"""Return whether two filesystem paths resolve to the same realpath."""
try:
return os.path.realpath(a) == os.path.realpath(b)
except OSError:
return a == b
@dataclass
class _MirrorItem:
"""One conversation item ready to POST, plus the message id that produced it."""
msg_id: int
item_type: str
item_data: dict[str, object]
response_id: str
#: The source ``messages`` row role ("user"/"assistant"/"tool"). Carried so a
#: row that yields no renderable item (a sentinel, ``item_type == ""``) still
#: exposes its role to turn detection — an empty-prose ``assistant`` terminal
#: row must still close the turn (see :func:`_mirror_item_role`).
role: str | None = None
def _message_to_items(
msg_id: int,
role: object,
content: object,
tool_calls: object,
tool_call_id: object,
tool_name: object, # noqa: ARG001 — reserved for future use (e.g. logging)
reasoning_content: object,
reasoning: object,
agent_name: str,
) -> list[_MirrorItem]:
"""Convert one ``messages`` row to mirror items.
An assistant row with reasoning emits a one-shot
``external_output_reasoning_delta`` item first, then a ``function_call``
item per call, followed by a ``message`` item if it also has prose content.
A tool row emits a ``function_call_output`` item. Returns an empty list to
skip.
"""
if not isinstance(role, str):
return []
text = ""
if isinstance(content, str):
text = _ATTACHMENT_MARKER_RE.sub("", content).strip()
response_id = f"hermes:{msg_id}"
if role == "user":
if not text:
return []
# Hermes injects skill content as a user message — replace with
# a short summary so the chat view stays readable.
skill_match = _SKILL_INVOKE_RE.match(text)
if skill_match:
text = f"/{skill_match.group('name')}"
return [
_MirrorItem(
msg_id=msg_id,
item_type="message",
item_data={"role": "user", "content": [{"type": "input_text", "text": text}]},
response_id=response_id,
)
]
if role == "assistant":
items: list[_MirrorItem] = []
# Hermes persists completed reasoning rows rather than deltas, so mirror
# the first available reasoning field as one event before the response.
thinking = ""
for raw in (reasoning_content, reasoning):
if isinstance(raw, str):
stripped = _ATTACHMENT_MARKER_RE.sub("", raw).strip()
if stripped:
thinking = stripped
break
if thinking:
items.append(
_MirrorItem(
msg_id=msg_id,
item_type=_EXTERNAL_OUTPUT_REASONING_DELTA,
item_data={"delta": thinking, "started": True},
response_id=response_id,
)
)
# Emit the prose FIRST, then the tool calls. An assistant row's text is
# the model's preamble ("I'll run X…") that precedes the calls it makes
# in the same step, so the natural order is message → function_call(s).
# It also matters for live rendering: the web only shows the running
# spinner on the TRAILING tool phase, so a message emitted AFTER the
# calls would leave the in-flight tool non-trailing (no spinner) until
# its output lands.
if text:
items.append(
_MirrorItem(
msg_id=msg_id,
item_type="message",
item_data={
"role": "assistant",
"agent": agent_name,
"content": [{"type": "output_text", "text": text}],
},
response_id=response_id,
)
)
# Parse tool_calls JSON — assistant rows may include tool call requests.
if isinstance(tool_calls, str) and tool_calls:
try:
calls = json.loads(tool_calls)
except (json.JSONDecodeError, ValueError):
calls = []
if isinstance(calls, list):
for call in calls:
if not isinstance(call, dict):
continue
call_id = call.get("call_id") or call.get("id") or ""
func = call.get("function", {})
name = func.get("name", "") if isinstance(func, dict) else ""
arguments = func.get("arguments", "{}") if isinstance(func, dict) else "{}"
if call_id and name:
items.append(
_MirrorItem(
msg_id=msg_id,
item_type="function_call",
item_data={
"agent": agent_name,
"name": name,
"arguments": arguments,
"call_id": call_id,
},
response_id=response_id,
)
)
return items
if role == "tool":
# Tool result row — emit function_call_output.
if isinstance(tool_call_id, str) and tool_call_id:
output = text or ""
return [
_MirrorItem(
msg_id=msg_id,
item_type="function_call_output",
item_data={"call_id": tool_call_id, "output": output},
response_id=response_id,
)
]
return []
return []
def _read_new_items(
db_path: Path, hermes_session_id: str, last_id: int, agent_name: str
) -> list[_MirrorItem]:
"""Read ``messages`` rows with ``id > last_id`` for this session as items.
A skipped row (tool/system/empty/inactive) still advances the cursor via a
sentinel item so it is never reconsidered.
"""
con = _connect_ro(db_path)
if con is None:
return []
# ``messages.active`` (compaction soft-delete flag) was dropped in newer
# Hermes schemas; only filter on it when present.
cols = _table_columns(con, "messages")
active_filter = " AND active = 1" if "active" in cols else ""
# ``reasoning_content`` / ``reasoning`` were added in newer schemas; SELECT
# them only when present so a v11 (or other older) DB does not raise
# ``no such column``.
reasoning_cols = ""
if "reasoning_content" in cols:
reasoning_cols += ", reasoning_content"
if "reasoning" in cols:
reasoning_cols += ", reasoning"
try:
rows = con.execute(
"SELECT id, role, content, tool_calls, tool_call_id, tool_name "
f"{reasoning_cols} "
"FROM messages "
f"WHERE session_id = ? AND id > ?{active_filter} ORDER BY id",
(hermes_session_id, last_id),
).fetchall()
except sqlite3.Error as exc:
_warn_sqlite_once("message read", exc)
return []
finally:
con.close()
items: list[_MirrorItem] = []
for row in rows:
# The trailing reasoning_content / reasoning columns are present
# only when the live schema carries them; unpack positionally.
msg_id, role, content, tool_calls_json, tool_call_id, tool_name_val = row[:6]
reasoning_content = row[6] if len(row) > 6 and "reasoning_content" in cols else None
reasoning = row[-1] if len(row) > 6 and "reasoning" in cols else None
converted = _message_to_items(
msg_id,
role,
content,
tool_calls_json,
tool_call_id,
tool_name_val,
reasoning_content,
reasoning,
agent_name,
)
if converted:
items.extend(converted)
else:
# A skipped row (empty/tool/system) still advances the cursor via a
# sentinel; carry its role so turn detection can still see, e.g., an
# empty-prose ``assistant`` terminal row and close the turn.
items.append(
_MirrorItem(
msg_id=msg_id,
item_type="",
item_data={},
response_id="",
role=role if isinstance(role, str) else None,
)
)
return items
def _drop_delivered_prefix(
items: list[_MirrorItem], row_id: int, delivered: int
) -> list[_MirrorItem]:
"""Drop the *delivered* leading items of row *row_id* from a re-read batch.
A row whose mirroring failed partway is re-read whole so its undelivered items
still land; its already-posted prefix is removed here so they are not mirrored
twice. Items of other rows pass through untouched, so if *row_id* is gone from
the batch (compaction soft-deleted it before the retry) this is a no-op rather
than trimming some other row.
"""
kept: list[_MirrorItem] = []
seen = 0
for it in items:
if it.msg_id != row_id:
kept.append(it)
continue
# Count position within the row rather than compare items: two items of one
# row can be equal (identical repeated tool calls), so identity is the index.
# A row shorter than the recorded offset (schema/parse change) drops entirely:
# re-posting a delivered item duplicates it, which no later poll can undo.
if seen >= delivered:
kept.append(it)
seen += 1
return kept
@dataclass
class _TurnAction:
"""One ordered step when mirroring a poll batch.
``kind`` is ``"running"`` (POST a ``running`` status edge) or ``"item"`` (POST
a mirrored conversation item). ``turn_id_after`` is the turn id still active
once this step is applied — persisted after each step so a turn that spans
polls (or a forwarder restart mid-turn) keeps its id. ``last_of_row`` marks the
final item of a ``msg_id`` group, the only point at which the row is fully
mirrored and the cursor may advance past it.
"""
kind: str
msg_id: int
turn_id_after: str | None
response_id: str | None = None
item: _MirrorItem | None = None
last_of_row: bool = False
def _mirror_item_role(item: _MirrorItem) -> str | None:
"""Return the source-row role of a mirror item for turn detection.
A ``message`` item reads it from ``item_data``; a sentinel (``item_type ==
""``, produced for a row that yields no renderable item) reads the row role
carried on the item — so an empty-prose ``assistant`` terminal row is still
seen as an assistant row and closes the turn. Other item types (function
calls / outputs) return ``None``; ``has_function_call`` covers those.
"""
if item.item_type == "message":
role = item.item_data.get("role")
return role if isinstance(role, str) else None
if item.item_type == "":
return item.role
return None
def _annotate_turn_actions(
items: list[_MirrorItem], active_turn_id: str | None
) -> tuple[list[_TurnAction], str | None]:
"""Assign a per-turn ``response_id`` to a poll batch and interleave ``running``
edges at turn starts; return the ordered actions and the turn id still active
after the batch.
A Hermes turn is ``user -> (assistant+tool_calls -> tool)* ->
assistant-without-tool_calls``. Rows arrive append-only in ``id`` order and each
``messages`` row is a single role, so items are grouped by ``msg_id``:
- a ``user`` group **opens** a turn → mint ``hermes_turn_{msg_id}`` and emit a
``running`` edge before its items;
- assistant activity while no turn is active also mints one (missed-start
recovery — e.g. a forwarder that starts mid-turn), so its cards still go live;
- every mirrored item is re-stamped with the active turn id so the web renders
the turn's tool-call cards live against the ``running`` edge;
- an ``assistant`` group with **no** ``function_call`` item is the terminal step
→ clear the id after it.
The ``idle`` edge is intentionally NOT emitted here: the completed-turn idle
post settles the card when the turn closes. A turn that never writes a terminal
row (some aborts) keeps its id active — the poll loop's ``running`` re-assert
holds the card live until the next turn replaces the id (see module docstring).
"""
actions: list[_TurnAction] = []
for msg_id, group_iter in groupby(items, key=lambda it: it.msg_id):
group = list(group_iter)
has_function_call = any(it.item_type == "function_call" for it in group)
roles = {_mirror_item_role(it) for it in group}
opens = "user" in roles
is_assistant = "assistant" in roles
terminal = is_assistant and not has_function_call
if opens or (active_turn_id is None and (has_function_call or is_assistant)):
active_turn_id = f"hermes_turn_{msg_id}"
actions.append(
_TurnAction("running", msg_id, active_turn_id, response_id=active_turn_id)
)
for ix, it in enumerate(group):
if active_turn_id is not None:
it.response_id = active_turn_id
actions.append(
_TurnAction(
"item",
msg_id,
active_turn_id,
item=it,
last_of_row=ix == len(group) - 1,
)
)
if terminal:
active_turn_id = None
if actions:
actions[-1].turn_id_after = None
return actions, active_turn_id
def _assistant_row_has_tool_calls(tool_calls: object) -> bool:
"""Whether an assistant ``messages`` row carries a non-empty ``tool_calls`` list.
Hermes writes one ``messages`` row per agentic step (complete, append-only —
rows are never updated in place, which is why message mirroring keys off
``id > last_id``). An assistant row with one or more tool calls means the loop
continues (a tool result + further assistant step follow); a row with no tool
calls is the loop's terminal step — the model returning its final answer.
Mirrors the ``tool_calls`` parsing in :func:`_message_to_items`.
"""
if not isinstance(tool_calls, str) or not tool_calls.strip():
return False
try:
calls = json.loads(tool_calls)
except (json.JSONDecodeError, ValueError):
return False
return isinstance(calls, list) and len(calls) > 0
def _count_completed_turns(
db_path: Path, hermes_session_id: str, max_id: int | None = None
) -> int:
"""Count completed turns for *hermes_session_id* (0 on unreadable/empty).
A completed turn is an ``assistant`` row with no ``tool_calls`` — the agentic
loop's terminal step (see :func:`_assistant_row_has_tool_calls`). Rows are
counted regardless of the ``active`` flag: Hermes soft-deletes on compaction
(sets ``active = 0``) rather than deleting rows, so ignoring it keeps the
count monotonic and append-only — the dedup baseline can then only grow, never
drop below the posted-count and falsely re-arm an idle post for an old turn.
With *max_id*, only rows at or below that id are counted. The idle check
passes the mirror's high-water mark here so a terminal row that lands while
a batch is still being POSTed cannot be counted — and ring the parent-waking
idle edge — before the row itself has been mirrored.
"""
con = _connect_ro(db_path)
if con is None:
return 0
query = "SELECT tool_calls FROM messages WHERE session_id = ? AND role = 'assistant'"
params: tuple[object, ...] = (hermes_session_id,)
if max_id is not None:
query += " AND id <= ?"
params = (hermes_session_id, max_id)
try:
rows = con.execute(query + " ORDER BY id", params).fetchall()
except sqlite3.Error as exc:
_warn_sqlite_once("turn-end count", exc)
return 0
finally:
con.close()
return sum(1 for (tool_calls,) in rows if not _assistant_row_has_tool_calls(tool_calls))
async def _post_external_session_status(
client: httpx.AsyncClient,
*,
session_id: str,
status: str,
response_id: str | None = None,
) -> None:
"""POST one ``external_session_status`` event to the Sessions API.
For a sub-agent conversation the server maps an ``idle`` edge to a terminal
completion that wakes the parent orchestrator's inbox — the SAME contract
claude-/codex-/opencode-/cursor-native use. The runner's PTY-activity watcher
emits only a web-spinner ``session.status`` edge for hermes-native and never
wakes a parent, which is why this explicit post is required.
When *response_id* is given (the turn's ``hermes_turn_{id}``), the edge carries
it: a ``running`` edge marks that response id active so the web renders the
turn's tool-call cards live, and a clean-close ``idle`` names the card to settle
(an id-less idle is a no-op on the web while a response is still streaming). An
``idle`` with no id still resolves via the server popping the active id and the
snapshot refetch — the abort / turn-spanned-a-prior-batch path.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
data: dict[str, object] = {"status": status}
if response_id is not None:
data["response_id"] = response_id
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_status", "data": data},
)
resp.raise_for_status()
async def _post_conversation_item(
client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem
) -> None:
"""POST one mirrored item as the appropriate session event.
Reasoning items post a transient ``external_output_reasoning_delta`` (the
web finalizes the block when the assistant message lands); all others post
an ``external_conversation_item``.
"""
if item.item_type == _EXTERNAL_OUTPUT_REASONING_DELTA:
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": _EXTERNAL_OUTPUT_REASONING_DELTA,
"data": item.item_data,
},
)
resp.raise_for_status()
return
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "external_conversation_item",
"data": {
"item_type": item.item_type,
"item_data": item.item_data,
"response_id": item.response_id,
},
},
)
resp.raise_for_status()
def _has_new_compaction(db_path: Path, hermes_session_id: str) -> bool:
"""Check if hermes has compacted messages for this session."""
con = _connect_ro(db_path)
if con is None:
return False
# ``messages.compacted`` was dropped in newer Hermes schemas; without it we
# cannot detect a compaction boundary here (safe: no boundary item posted).
if "compacted" not in _table_columns(con, "messages"):
con.close()
return False
try:
row = con.execute(
"SELECT 1 FROM messages WHERE session_id = ? AND compacted = 1 LIMIT 1",
(hermes_session_id,),
).fetchone()
return row is not None
except sqlite3.Error:
return False
finally:
con.close()
async def _persist_hermes_compaction_item(
client: httpx.AsyncClient,
*,
session_id: str,
db_path: Path,
hermes_session_id: str,
) -> None:
"""Persist a compaction boundary item with post-compaction messages."""
resp = await client.get(
f"/v1/sessions/{session_id}/items",
params={"limit": 1, "order": "desc"},
)
resp.raise_for_status()
items = resp.json().get("data", [])
last_item_id = items[0]["id"] if items else f"compact_boundary_{session_id}"
compacted_messages = None
con = _connect_ro(db_path)
if con is not None:
_active = " AND active = 1" if "active" in _table_columns(con, "messages") else ""
try:
rows = con.execute(
f"SELECT role, content FROM messages WHERE session_id = ?{_active} ORDER BY id",
(hermes_session_id,),
).fetchall()
msgs = []
for role, content in rows:
if role in ("user", "assistant") and content:
block_type = "input_text" if role == "user" else "output_text"
msgs.append(
{
"type": "message",
"role": role,
"content": [{"type": block_type, "text": content}],
}
)
if msgs:
compacted_messages = msgs
except sqlite3.Error as exc:
_warn_sqlite_once("compaction read", exc)
finally:
con.close()
data: dict[str, object] = {
"summary": "[Hermes compaction — context was compacted via /compress]",
"last_item_id": last_item_id,
"model": "unknown",
"token_count": 0,
}
if compacted_messages:
data["compacted_messages"] = compacted_messages
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "compaction", "data": data},
)
resp.raise_for_status()
async def forward_hermes_store_to_session(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
agent_name: str,
workspace: str,
launch_epoch_s: float,
db_path: Path | None = None,
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
auth: httpx.Auth | None = None,
) -> None:
"""Tail Hermes' session store and mirror new messages into the AP session.
Discovers this session's Hermes ``sessions.id`` (newest row whose ``cwd``
matches *workspace* and ``started_at`` is at/after ``launch_epoch_s``), then
polls its ``messages`` rows, posting each new user/assistant row as an
``external_conversation_item``. The high-water ``id`` is persisted to
``bridge_dir`` so a supervisor restart resumes without re-posting.
:param base_url: Omnigent server base URL.
:param headers: Static HTTP headers (auth normally via ``auth``).
:param session_id: Omnigent session/conversation id.
:param bridge_dir: The hermes-native bridge dir (holds the persisted cursor).
:param agent_name: Agent label stamped on mirrored assistant items.
:param workspace: The session's working directory (Hermes' ``sessions.cwd``).
:param launch_epoch_s: Wall-clock seconds when this terminal launched.
:param db_path: Hermes state DB; defaults to :func:`default_state_db`.
:param poll_interval_s: Seconds between store polls.
:param auth: Optional refresh-capable httpx Auth for remote deployments.
:returns: Never normally returns; cancel the task to stop it.
"""
db = db_path or default_state_db()
persisted = _read_state(bridge_dir)
hermes_session_id: str | None = persisted.hermes_session_id
last_id = persisted.last_id if hermes_session_id is not None else 0
# A row whose mirroring failed partway, and how many of its items already
# posted; both ``0`` when ``last_id`` is a clean fully-mirrored high-water mark.
# Only meaningful alongside ``last_id``, so all three reset together.
partial_row_id = persisted.partial_row_id if hermes_session_id is not None else 0
partial_row_items = persisted.partial_row_items if hermes_session_id is not None else 0
# The turn currently in flight (its shared ``response_id``), threaded through
# every ``_write_state`` so it survives polls / a restart. Reset whenever the
# tailed hermes session changes (discovery, claim-yield, compaction re-pin).
active_turn_id: str | None = (
persisted.active_turn_id if hermes_session_id is not None else None
)
# Track whether we have already PATCHed the external_session_id to the
# Omnigent server so we do it at most once per forwarder lifetime.
_external_id_synced = False
timeout = httpx.Timeout(_POST_TIMEOUT_S)
from omnigent.cli_auth import open_server_client
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
usage_tracker = _HermesUsageTracker(client, session_id, bridge_dir)
compaction_persisted = False
while True:
try:
if hermes_session_id is None:
resolved = await asyncio.to_thread(
_discover_session_id, db, workspace, launch_epoch_s
)
if resolved is not None and not await asyncio.to_thread(
_session_claimed_by_other, bridge_dir, resolved, launch_epoch_s
):
hermes_session_id = resolved
resuming = persisted.hermes_session_id == resolved
last_id = persisted.last_id if resuming else 0
partial_row_id = persisted.partial_row_id if resuming else 0
partial_row_items = persisted.partial_row_items if resuming else 0
# Discovery only (re)binds on a cold start or a
# claim-yield / compaction re-pin reacquire — never the
# mid-turn restart-resume case, which keeps its session
# pinned and skips this block. So always start turn
# tracking fresh here; restoring the one-shot ``persisted``
# snapshot could resurrect a stale turn id on reacquire.
active_turn_id = None
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=resolved,
last_id=last_id,
partial_row_id=partial_row_id,
partial_row_items=partial_row_items,
launch_epoch_s=launch_epoch_s,
active_turn_id=active_turn_id,
),
)
# PATCH the external_session_id once so the server
# knows which Hermes session backs this conversation
# (needed for fork/resume).
if hermes_session_id is not None and not _external_id_synced:
try:
resp = await client.patch(
f"/v1/sessions/{session_id}",
json={"external_session_id": hermes_session_id},
)
resp.raise_for_status()
_external_id_synced = True
except httpx.HTTPError:
_logger.debug(
"hermes forwarder failed to PATCH external_session_id; "
"will retry next poll; session=%s",
session_id,
exc_info=True,
)
if hermes_session_id is not None:
# Yield to an earlier-launched live session rather than mirror
# the same row into a second conversation; re-discover next poll.
if await asyncio.to_thread(
_session_claimed_by_other, bridge_dir, hermes_session_id, launch_epoch_s
):
_logger.warning(
"hermes session %s already mirrored by another session; "
"pausing mirror for session=%s",
hermes_session_id,
session_id,
)
hermes_session_id = None
active_turn_id = None
else:
# ``last_id`` is the last row whose items ALL posted, so a row
# that failed partway is re-read here. Drop the items of it
# that already posted: the item POST carries no idempotency
# key, so a replay would duplicate them in the conversation.
items = await asyncio.to_thread(
_read_new_items, db, hermes_session_id, last_id, agent_name
)
# Dropping every item leaves the cursor parked until a newer
# row lands, which is correct: there is nothing left to
# deliver for it, and the next row restarts the count.
if partial_row_id:
items = _drop_delivered_prefix(
items, partial_row_id, partial_row_items
)
# Assign a per-turn response_id and interleave ``running``
# edges at turn starts; items are re-stamped in place so
# the turn's tool-call cards render live on the web.
turn_actions, active_turn_id = _annotate_turn_actions(
items, active_turn_id
)
# The response_id of the last turn that closed in this
# batch (its terminal step clears ``turn_id_after``). Fed
# to the completed-turn ``idle`` post below so the web
# settles that exact card deterministically — an id-less
# idle is a no-op while a response is still streaming.
closed_turn_id: str | None = None
# Whether this batch already posted a ``running`` edge (turn
# open), so the in-flight re-assert below doesn't duplicate it.
running_posted_this_batch = False
for action in turn_actions:
if action.kind == "running":
if action.response_id is not None:
running_posted_this_batch = True
# Best-effort: the running edge only makes the
# turn's cards render live. If it fails, mirroring
# (and the idle/PTY-watcher resolution) must still
# proceed — never abort the turn for a live-card post.
try:
await _post_external_session_status(
client,
session_id=session_id,
status="running",
response_id=action.response_id,
)
except Exception: # noqa: BLE001 — live-card edge is best-effort
_logger.debug(
"hermes forwarder running-edge post failed; "
"cards may not go live; session=%s",
session_id,
exc_info=True,
)
# A running edge mirrors no message row, so it must
# NOT advance the ``last_id`` cursor. The opening
# group's item action (same msg_id, next iteration)
# advances it only AFTER its row is POSTed — so a
# crash in the window re-reads the opening row on
# restart instead of skipping it.
continue
if action.item is not None and action.item.item_type:
await _post_conversation_item(
client, session_id=session_id, item=action.item
)
if (
action.turn_id_after is None
and action.item is not None
and action.item.response_id
):
closed_turn_id = action.item.response_id
# A row expands to several items, so the cursor may only
# advance once the LAST one lands. Until then record how
# far into the row we got: a POST that fails on a later
# item then resumes inside the row on the next poll,
# instead of ``last_id`` moving past it and the rest of
# its items being skipped forever.
if action.last_of_row:
last_id = action.msg_id
partial_row_id = 0
partial_row_items = 0
else:
# Count from 1 on a row we were not already inside.
# A partial row can disappear before its retry
# (compaction soft-deletes it, and the re-pin that
# resets these is skipped when the session has no
# child), so carrying its count into the next row
# would over-drop that row's items as delivered.
if partial_row_id != action.msg_id:
partial_row_items = 0
partial_row_id = action.msg_id
partial_row_items += 1
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=hermes_session_id,
last_id=last_id,
partial_row_id=partial_row_id,
partial_row_items=partial_row_items,
launch_epoch_s=launch_epoch_s,
active_turn_id=action.turn_id_after,
),
)
if not compaction_persisted and await asyncio.to_thread(
_has_new_compaction, db, hermes_session_id
):
try:
await _persist_hermes_compaction_item(
client,
session_id=session_id,
db_path=db,
hermes_session_id=hermes_session_id,
)
compaction_persisted = True
except Exception: # noqa: BLE001
_logger.warning(
"Failed to persist hermes compaction item for %s",
session_id,
exc_info=True,
)
# Compaction ends this Hermes session and forks a
# child (parent_session_id chain). Re-pin to the
# newest child so the mirror follows the live session
# instead of polling the dead parent forever. Done at
# most once per compaction (compaction_persisted resets
# to False for the child, which carries no compacted
# rows yet); if no child exists we stay on the parent.
try:
child = await asyncio.to_thread(
_discover_child_session, db, hermes_session_id
)
if child is not None and not await asyncio.to_thread(
_session_claimed_by_other, bridge_dir, child, launch_epoch_s
):
hermes_session_id = child
last_id = 0
partial_row_id = 0
partial_row_items = 0
active_turn_id = None
compaction_persisted = False
_external_id_synced = False
# The idle dedup baseline is per-terminal but
# the completed-turn count is per
# hermes_session_id; the child restarts its
# count near 0, so rebase the baseline to the
# child's current count. Without this the guard
# `completed_turns > posted_count` stays False
# until the child exceeds the parent's total,
# suppressing idle posts for the child's first
# turns — a worker that compacts then finishes
# would never wake its parent.
await asyncio.to_thread(
hermes_native_status.write_posted_count,
bridge_dir,
await asyncio.to_thread(_count_completed_turns, db, child),
)
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=child,
last_id=0,
partial_row_id=0,
partial_row_items=0,
launch_epoch_s=launch_epoch_s,
active_turn_id=None,
),
)
continue
except Exception: # noqa: BLE001
_logger.warning(
"hermes forwarder failed to re-pin to child session "
"after compaction; staying on %s; session=%s",
hermes_session_id,
session_id,
exc_info=True,
)
# Post model/usage data after mirroring messages.
await usage_tracker.flush()
# Re-assert ``running`` for a turn still in flight that did
# not open this batch. Hermes leaves the tmux pane quiet
# during a silent tool (e.g. ``sleep``), so the runner's
# PTY-activity watcher fires an id-less ``idle`` after ~1s
# and the server pops the turn's active_response_id — which
# would let a snapshot refetch settle the live card early.
# Re-posting the turn's ``running`` each poll (0.4s) re-arms
# that id well inside the 1s window, so the card stays live
# until the real terminal step. Best-effort: a live-card edge
# never blocks mirroring.
if active_turn_id is not None and not running_posted_this_batch:
try:
await _post_external_session_status(
client,
session_id=session_id,
status="running",
response_id=active_turn_id,
)
except Exception: # noqa: BLE001 — live-card edge is best-effort
_logger.debug(
"hermes forwarder running re-assert failed; "
"card may settle early; session=%s",
session_id,
exc_info=True,
)
# Refresh the claim heartbeat every poll (even with no new
# items) so an idle owner keeps its claim.
_write_state(
bridge_dir,
_ForwardState(
hermes_session_id=hermes_session_id,
last_id=last_id,
partial_row_id=partial_row_id,
partial_row_items=partial_row_items,
launch_epoch_s=launch_epoch_s,
active_turn_id=active_turn_id,
),
)
# Turn each newly-completed turn into an
# ``external_session_status: idle`` edge — the signal that
# wakes a parent orchestrator (the PTY watcher's spinner
# status never does). A completed turn is an assistant row
# with no tool_calls (the agentic loop's terminal step);
# posted only AFTER its messages are mirrored above so the
# parent sees the content before the completion — the
# count is bounded by the mirrored high-water mark, so a
# terminal row landing while this poll's batch was being
# POSTed waits for the next poll to mirror it. Deduped
# against a persisted posted-count so a supervisor restart
# never re-wakes the parent for a turn it already reported.
# Best-effort: a failed post raises into the outer handler
# and leaves the count unadvanced, so the next poll retries.
completed_turns = await asyncio.to_thread(
_count_completed_turns, db, hermes_session_id, last_id
)
if completed_turns > await asyncio.to_thread(
hermes_native_status.read_posted_count, bridge_dir
):
# Carry the closed turn's response_id when this batch
# observed the terminal step, so the web settles that
# card deterministically (matching codex-native). A
# retry where the terminal row landed in a prior batch
# has no id here and posts id-less — the PTY watcher's
# idle (which pops the active id server-side) plus the
# snapshot refetch still resolve it, as on abort.
await _post_external_session_status(
client,
session_id=session_id,
status="idle",
response_id=closed_turn_id,
)
await asyncio.to_thread(
hermes_native_status.write_posted_count,
bridge_dir,
completed_turns,
)
except asyncio.CancelledError:
raise
except Exception:
_logger.exception(
"hermes forwarder poll failed; session=%s hermes_session=%s",
session_id,
hermes_session_id,
)
await asyncio.sleep(poll_interval_s)
def _supervisor_monotonic() -> float:
"""Indirection so tests can stub the supervisor's clock."""
return time.monotonic()
async def _supervisor_sleep(seconds: float) -> None:
"""Indirection so tests can stub the supervisor's backoff sleep."""
await asyncio.sleep(seconds)
async def supervise_hermes_forwarder(
*,
base_url: str,
headers: dict[str, str],
session_id: str,
bridge_dir: Path,
agent_name: str,
workspace: str,
launch_epoch_s: float,
db_path: Path | None = None,
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
auth: httpx.Auth | None = None,
) -> None:
"""Run :func:`forward_hermes_store_to_session` under a restart supervisor.
Mirrors :func:`omnigent.goose_native_forwarder.supervise_goose_forwarder`:
bounded exponential backoff, :class:`asyncio.CancelledError` propagates for
clean teardown, and the persisted ``id`` cursor means restarts resume exactly
where they left off.
:returns: Never normally returns; cancel the task to stop it.
"""
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
while True:
run_started_at = _supervisor_monotonic()
crash_exc: Exception | None = None
try:
await forward_hermes_store_to_session(
base_url=base_url,
headers=headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name=agent_name,
workspace=workspace,
launch_epoch_s=launch_epoch_s,
db_path=db_path,
poll_interval_s=poll_interval_s,
auth=auth,
)
_logger.warning(
"hermes forwarder returned unexpectedly; restarting; session=%s bridge_dir=%s",
session_id,
bridge_dir,
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 — supervisor restarts on any Exception
crash_exc = exc
if _supervisor_monotonic() - run_started_at >= _SUPERVISOR_HEALTHY_UPTIME_S:
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
if crash_exc is not None:
_logger.error(
"hermes forwarder crashed; restarting in %.1fs; session=%s bridge_dir=%s",
backoff_s,
session_id,
bridge_dir,
exc_info=crash_exc,
)
await _supervisor_sleep(backoff_s)
backoff_s = min(backoff_s * 2.0, _SUPERVISOR_MAX_BACKOFF_S)