From ee0c7cfe9524d60e0671ec357461dbfe7a7d7ac9 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Thu, 21 May 2026 19:38:37 +0000 Subject: [PATCH 01/10] Add NeMo-Flow integration to Hermes agent - Updated the Dockerfile to build and install the `nemo-flow` CLI binary, enabling observability features. - Introduced a new `nemo-flow-finalize-shim` script to ensure proper session finalization and ATIF file generation for each conversation turn. - Enhanced the `generate-config.ts` to include NeMo-Flow shell hooks for event handling. - Updated the `start.sh` script to launch Hermes with the `nemo-flow` wrapper, facilitating telemetry and observability. - Added configuration files for NeMo-Flow, including `nemo-flow-plugins.toml.in` for observability settings. Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 121 +++++++++++------- .../agents/hermes/generate-config.ts | 34 +++++ .../agents/hermes/manifest.yaml | 2 +- .../agents/hermes/nemo-flow-finalize-shim | 54 ++++++++ .../agents/hermes/nemo-flow-plugins.toml.in | 24 ++++ .../agents/hermes/patches/nemoclaw_patches.py | 84 +----------- .../agents/hermes/patches/sitecustomize.py | 6 +- .../agents/hermes/start.sh | 60 +++------ .../policy.yaml | 1 + 9 files changed, 221 insertions(+), 165 deletions(-) create mode 100644 examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim create mode 100644 examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index 0b1f9ae4..b9859a30 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -35,6 +35,24 @@ RUN pyinstaller \ --hidden-import aiohttp.web \ ms_graph_sidecar.py +# ── Stage 1: build the nemo-flow CLI binary ────────────────────────────────── +# Built inside ${BASE_IMAGE} so the resulting binary links against the same +# glibc as the runtime — same constraint as the sidecar-builder stage above. +# 0.2.0 publishes nemo-flow-cli to crates.io, so `cargo install` is the +# simplest reproducible path. The ~3-5 min toolchain bootstrap is amortized +# across the cargo target cache when the version pin doesn't change. +FROM ${BASE_IMAGE} AS nemo-flow-builder +ARG NEMO_FLOW_CLI_VERSION=0.2.0 +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal \ + && /root/.cargo/bin/cargo install --locked --root /out \ + nemo-flow-cli --version "${NEMO_FLOW_CLI_VERSION}" +# Produces /out/bin/nemo-flow + # ── Main sandbox image ──────────────────────────────────────────────────────── FROM ${BASE_IMAGE} @@ -91,55 +109,72 @@ RUN mkdir -p /etc/apt/keyrings \ RUN pip3 install --no-cache-dir --break-system-packages "httpx>=0.27" "markdown-it-py>=3" -# ── NeMo-Flow patched Hermes (always installed) ───────────────────────── -# Replaces the unpatched Hermes from the base image with a NeMo-Flow-patched -# build so the agent writes ATIF (Agent Trajectory Format) traces to -# /tmp/atif. nemo-flow itself comes from PyPI. The patch itself patches -# Hermes — adding a [nemo-flow] extra, a hermes_agent.plugins entry point, -# and an ACG override seam in run_agent.py — so it's still required even -# with the PyPI install of nemo_flow itself. +# ── NeMo-Flow 0.2.0 wrapped-CLI integration ───────────────────────────── +# Drop-in observability via `nemo-flow hermes -- gateway run`. Hermes stays +# unpatched (from BASE_IMAGE); native hook events configured in +# ~/.hermes/config.yaml route through the wrapper's ephemeral gateway, which +# runs the ATIF writer and OpenInference OTLP exporter from the baked +# /etc/nemo-flow/plugins.toml. No Hermes patch, no nemo-flow PyPI package, +# no HERMES_NEMO_FLOW_* env vars. # # ATIF writes are local-disk-only — no collector required. To additionally -# stream OpenInference traces to Phoenix, set PHOENIX_COLLECTOR_ENDPOINT in -# .env; that's a separate runtime knob and does not gate this install. +# stream OpenInference traces to Phoenix, set PHOENIX_COLLECTOR_ENDPOINT; +# empty disables OpenInference but keeps ATIF on. # -# The patch is fetched from NeMo-Flow's GitHub tag at build time rather than -# vendored, so we never drift from upstream. Pin NEMO_FLOW_VERSION to the -# same release we install via pip; HERMES_NEMO_FLOW_COMMIT must match the -# Hermes commit that NeMo-Flow's patch was generated against (see -# https://github.com/NVIDIA/NeMo-Flow/blob/v0.1.0/third_party/sources.lock). -# agents/hermes/patches/ is example-owned (not in NeMo-Flow upstream) so it -# stays vendored. It holds the PYTHONPATH-targeted sitecustomize.py bootstrap -# and the nemoclaw_patches.py chain-loaded bundle (httpx transport fix, Slack -# catch-all, NeMo-Flow session finalization). -ARG ENABLE_NEMO_FLOW=1 -ARG NEMO_FLOW_VERSION=0.1.0 -ARG HERMES_NEMO_FLOW_COMMIT=2367c6ffd53b16daa0ffa1b338f3f9ee5587d4d5 +# agents/hermes/patches/ is example-owned (not in NeMo-Flow upstream). It +# holds the PYTHONPATH-targeted sitecustomize.py bootstrap (Slack-SDK +# placeholder rewrite) and the nemoclaw_patches.py chain-loaded bundle +# (Slack catch-all slash command). +COPY --from=nemo-flow-builder /out/bin/nemo-flow /usr/local/bin/nemo-flow +RUN chmod 755 /usr/local/bin/nemo-flow + +# Per-turn finalize shim. Hermes only fires `on_session_finalize` from its +# idle-session expiry watcher (~5 min default), but NeMo-Flow's ATIF writer +# and root-span closer only act on `on_session_finalize` / `on_session_reset`. +# This shim, registered as a second hook on `on_session_end`, rewrites the +# event name and re-posts to the gateway so each turn closes its agent scope. +COPY agents/hermes/nemo-flow-finalize-shim /usr/local/bin/nemo-flow-finalize-shim +RUN chmod 755 /usr/local/bin/nemo-flow-finalize-shim COPY agents/hermes/patches/ /usr/local/lib/nemoclaw-patches/ -# hadolint ignore=DL3013 -RUN if [ "$ENABLE_NEMO_FLOW" = "1" ]; then \ - echo "[nemo-flow] Installing nemo-flow==${NEMO_FLOW_VERSION} from PyPI and patching Hermes" \ - && curl -fsSL "https://raw.githubusercontent.com/NVIDIA/NeMo-Flow/${NEMO_FLOW_VERSION}/patches/hermes-agent/0001-add-nemo-flow-integration.patch" \ - -o /tmp/nemo-flow.patch \ - && curl -fsSL "https://github.com/NousResearch/hermes-agent/archive/${HERMES_NEMO_FLOW_COMMIT}.tar.gz" \ - | tar -xz -C /tmp/ \ - && mv "/tmp/hermes-agent-${HERMES_NEMO_FLOW_COMMIT}" /tmp/hermes-agent \ - && git -C /tmp/hermes-agent init -q \ - && git -C /tmp/hermes-agent apply /tmp/nemo-flow.patch \ - && pip3 install --no-cache-dir --break-system-packages --force-reinstall \ - "nemo-flow==${NEMO_FLOW_VERSION}" \ - "/tmp/hermes-agent[nemo-flow,slack]" \ - "pyyaml==6.0.3" \ - "python-telegram-bot>=21.0" \ - "httpx>=0.27" \ - "slack-bolt>=1.19" \ - && hermes --version \ - && export PY_SITE_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" \ - && ln -sfn /usr/local/lib/nemoclaw-patches/sitecustomize.py "${PY_SITE_DIR}/sitecustomize.py" \ - && rm -rf /tmp/hermes-agent /tmp/nemo-flow.patch; \ - fi +# Pin slack-bolt / python-telegram-bot / pyyaml / httpx into Hermes's venv +# interpreter so the sitecustomize patches (Slack catch-all, SDK placeholder +# rewrite) find the modules they wrap. hadolint ignore=DL3013 +RUN pip3 install --no-cache-dir --break-system-packages \ + "pyyaml==6.0.3" \ + "python-telegram-bot>=21.0" \ + "httpx>=0.27" \ + "slack-bolt>=1.19" \ + && hermes --version \ + && export PY_SITE_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" \ + && ln -sfn /usr/local/lib/nemoclaw-patches/sitecustomize.py "${PY_SITE_DIR}/sitecustomize.py" + +# ── NeMo-Flow plugin config (immutable, baked at build time) ──────────── +# Discovery order: /etc/nemo-flow → project ./.nemo-flow → user XDG +# (NeMo-Flow crates/cli/src/config.rs:618-633). /etc/ is the cleanest +# location for a containerized agent because it ignores CWD. +# +# Two files are baked here: +# config.toml — `[agents.hermes]` block so `nemo-flow hermes` finds a +# configured agent and skips the interactive setup wizard +# (which fails in non-TTY sandboxes). +# plugins.toml — observability component (ATIF + OpenInference). +COPY agents/hermes/nemo-flow-plugins.toml.in /tmp/nemo-flow-plugins.toml.in +RUN mkdir -p /etc/nemo-flow \ + && printf '%s\n' '[agents.hermes]' 'command = "hermes"' > /etc/nemo-flow/config.toml \ + && chmod 444 /etc/nemo-flow/config.toml \ + && PHOENIX_URL="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ + && if [ -z "$PHOENIX_URL" ]; then \ + PHOENIX_ENABLED=false; PHOENIX_ENDPOINT=""; \ + else \ + PHOENIX_ENABLED=true; PHOENIX_ENDPOINT="${PHOENIX_URL%/}/v1/traces"; \ + fi \ + && sed -e "s|@@PHOENIX_ENABLED@@|${PHOENIX_ENABLED}|g" \ + -e "s|@@PHOENIX_ENDPOINT@@|${PHOENIX_ENDPOINT}|g" \ + /tmp/nemo-flow-plugins.toml.in > /etc/nemo-flow/plugins.toml \ + && rm /tmp/nemo-flow-plugins.toml.in \ + && chmod 444 /etc/nemo-flow/plugins.toml # Hermes v2026.4.13+ auto-detects HTTPS_PROXY and skips fallback-IP # transport when a proxy is present. The sandbox proxy chain diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index a30438e6..0d9c7e4f 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -113,6 +113,40 @@ function main(): void { mode: "smart", timeout: 60, }, + // NeMo-Flow shell hooks — each event spawns `nemo-flow hook-forward hermes`, + // which reads the JSON payload from stdin and POSTs it to NEMO_FLOW_GATEWAY_URL + // (injected by the `nemo-flow hermes` wrapper). Events are the intersection of + // NeMo-Flow's HERMES_HOOK_EVENTS (installer.rs) and Hermes's VALID_HOOKS + // (hermes_cli/plugins.py). NeMo-Flow's "api_request_error" and "subagent_start" + // are forward-looking — current Hermes only exposes "subagent_stop" and reports + // request errors via "post_api_request" payloads, so we omit them here to + // avoid "unknown hook event" warnings. + // + // `on_session_end` gets a SECOND command (`nemo-flow-finalize-shim`) that + // synthesizes a per-turn `on_session_finalize`. Hermes fires real finalize + // only from its idle-session expiry watcher (~5 min default), but + // NeMo-Flow's ATIF writer and root-span closer only act on finalize. The + // shim closes the agent scope every turn so each conversation produces a + // complete Phoenix root span and a fresh ATIF JSON file. + hooks: (() => { + const fwd = { command: "/usr/local/bin/nemo-flow hook-forward hermes", timeout: 30 }; + const finalize_shim = { command: "/usr/local/bin/nemo-flow-finalize-shim", timeout: 30 }; + const events = [ + "on_session_start", "on_session_finalize", "on_session_reset", + "pre_llm_call", "post_llm_call", + "pre_api_request", "post_api_request", + "pre_tool_call", "post_tool_call", + "subagent_stop", + ]; + const result: Record = Object.fromEntries(events.map((ev) => [ev, [fwd]])); + result.on_session_end = [fwd, finalize_shim]; + return result; + })(), + // Auto-accept the hook commands. The sandbox is non-interactive; without + // this the first hook fires a TTY consent prompt and gets skipped, + // dropping all observability silently. Safe here because config.yaml is + // root-owned + chmod 444 at build time. + hooks_auto_accept: true, }; // Messaging platforms (if configured during onboard) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml index 67537569..09fef261 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml @@ -17,7 +17,7 @@ install_method: curl # curl install.sh | bash binary_path: /usr/local/bin/hermes version_command: "hermes --version" expected_version: "2026.4.8" -gateway_command: "hermes gateway run" +gateway_command: "hermes gateway run" # entrypoint wraps this with `nemo-flow hermes --` (see start.sh) # ── Health probe ──────────────────────────────────────────────── # The API server adapter listens on 8642 by default and exposes diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim new file mode 100644 index 00000000..d42c8336 --- /dev/null +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# NeMo-Flow gateway-mode finalize shim. +# +# Hermes fires `on_session_end` at every `run_conversation` turn boundary but +# only fires `on_session_finalize` from its idle-session expiry watcher every +# ~5 minutes. NeMo-Flow 0.2.0's gateway adapter (crates/cli/src/adapters/ +# hermes.rs:55-64) maps `on_session_end` to `TurnEnded`, which is documented +# as NOT closing the agent scope — and the ATIF writer in +# crates/core/src/observability/plugin_component.rs:684-685 only writes when +# the agent scope closes. Result: orphaned Phoenix spans and no ATIF files +# per turn. +# +# This shim is registered as a SECOND command on `on_session_end`. It rewrites +# the JSON payload's `hook_event_name` to `on_session_finalize` and pipes it +# back into `nemo-flow hook-forward hermes`, so the gateway closes the agent +# scope and writes ATIF every turn. The first command (the unmodified +# `hook-forward`) still delivers the on_session_end event for any other +# downstream handling. +import json +import os +import subprocess +import sys + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError: + # Empty or malformed stdin — emit a synthesized payload so the gateway + # still observes a finalize for whatever session_id we have. + payload = {} + if not isinstance(payload, dict): + payload = {} + payload["hook_event_name"] = "on_session_finalize" + rewritten = json.dumps(payload) + + proc = subprocess.run( + ["/usr/local/bin/nemo-flow", "hook-forward", "hermes"], + input=rewritten, + text=True, + env=os.environ.copy(), + check=False, + ) + # Hermes only inspects stdout when looking for hook decisions. Emit an + # empty object so the agent loop treats this as a silent observer. + sys.stdout.write("{}\n") + return proc.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in new file mode 100644 index 00000000..bb658008 --- /dev/null +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# NeMo-Flow gateway plugin config — installed at /etc/nemo-flow/plugins.toml. +# Substituted at image build time from PHOENIX_COLLECTOR_ENDPOINT. +# Schema: nemo_flow.observability.ObservabilityConfig +# (NeMo-Flow python/nemo_flow/observability.py). + +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = "/tmp/atif" + +[components.config.openinference] +enabled = @@PHOENIX_ENABLED@@ +transport = "http_binary" +endpoint = "@@PHOENIX_ENDPOINT@@" +service_name = "hermes-agent" +timeout_millis = 3000 diff --git a/examples/personal-community-sentiment-triage/agents/hermes/patches/nemoclaw_patches.py b/examples/personal-community-sentiment-triage/agents/hermes/patches/nemoclaw_patches.py index b15a34e7..ac0be85b 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/patches/nemoclaw_patches.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/patches/nemoclaw_patches.py @@ -6,50 +6,14 @@ # Bundled into the image at /usr/local/lib/nemoclaw-patches/nemoclaw_patches.py # and chain-loaded by the neighboring sitecustomize.py (which Python imports # preferentially because /usr/local/lib/nemoclaw-patches/ is first on -# PYTHONPATH). When ENABLE_NEMO_FLOW=1, ${PY_SITE_DIR}/sitecustomize.py is a -# symlink back to the same sitecustomize.py — a fallback for child processes -# that lose PYTHONPATH. +# PYTHONPATH). # -# The NeMo-Flow meta_path hook below is a no-op when nemo-flow isn't -# installed, so this file is safe to load regardless of build mode. -# -# Patch 1 — httpx transport fix: Hermes creates httpx.Client(transport=HTTPTransport(...)) -# for TCP keepalives. A custom transport bypasses HTTPS_PROXY env-var routing, -# so Hermes cannot reach inference.local (only reachable via the OpenShell L7 -# proxy). Strip transport= when HTTPS_PROXY is set so httpx falls back to its -# default proxy-aware transport. -# -# Patch 2 — NeMo-Flow observability: on_session_end is intentionally a no-op in -# nemo_flow (pitfall P-02 — avoids premature finalization for long-lived CLI -# sessions). In the gateway, every run_conversation call IS a complete session -# regardless of platform, so we finalize immediately on every on_session_end. -# on_session_finalize never fires from the API server or native platform paths. -# -# Patch 3 — Slack catch-all slash command: Hermes only registers "/hermes" as -# its bolt command handler. Any workspace-specific command name (e.g. -# /my-assistant) produces an "Unhandled request" warning. After SlackAdapter -# connects, register a catch-all that routes every other slash command through -# the same _handle_slash_command path. -import os as _os +# Slack catch-all slash command — Hermes only registers "/hermes" as its bolt +# command handler. Any workspace-specific command name (e.g. /my-assistant) +# produces an "Unhandled request" warning. After SlackAdapter connects, +# register a catch-all that routes every other slash command through the same +# _handle_slash_command path. import re as _re -import sys as _sys -import importlib.abc as _iabc -import importlib.util as _iutil - - -def _patch_httpx() -> None: - try: - import httpx - _orig = httpx.Client.__init__ - def _fixed(self, *a, **kw): - if "transport" in kw and ( - _os.environ.get("HTTPS_PROXY") or _os.environ.get("https_proxy") - ): - del kw["transport"] - _orig(self, *a, **kw) - httpx.Client.__init__ = _fixed - except Exception: - pass def _patch_slack_commands() -> None: @@ -66,7 +30,7 @@ async def _handle_unknown_command(ack, command, respond): cmd = command.get("command", "this command") await respond( f"I don't recognize `{cmd}`. " - "Send me a *direct message* or @mention me a to chat" + "Send me a *direct message* or @mention me to chat" ) return result @@ -75,38 +39,4 @@ async def _handle_unknown_command(ack, command, respond): pass -_patch_httpx() _patch_slack_commands() - - -class _PatchingLoader(_iabc.Loader): - def __init__(self, real: _iabc.Loader) -> None: - self._real = real - - def create_module(self, spec): # type: ignore[override] - m = getattr(self._real, "create_module", None) - return m(spec) if m else None - - def exec_module(self, mod) -> None: # type: ignore[override] - self._real.exec_module(mod) - def _gateway_session_end(session_id: str = "", platform: str = "", **_kw) -> None: - if session_id and getattr(mod, "_NEMO_FLOW_OK", False): - try: - mod._finalize(session_id, platform or "session_end") - except Exception: - pass - mod.on_session_end = _gateway_session_end - - -class _NemoFlowPatcher(_iabc.MetaPathFinder): - def find_spec(self, fullname: str, path, target=None): # type: ignore[override] - if fullname != "plugins.nemo_flow.observability": - return None - _sys.meta_path.remove(self) - spec = _iutil.find_spec(fullname) - if spec is not None: - spec.loader = _PatchingLoader(spec.loader) - return spec - - -_sys.meta_path.insert(0, _NemoFlowPatcher()) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/patches/sitecustomize.py b/examples/personal-community-sentiment-triage/agents/hermes/patches/sitecustomize.py index cef20120..84782a72 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/patches/sitecustomize.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/patches/sitecustomize.py @@ -249,15 +249,13 @@ def _nemoclaw_ws_connect(self, url, **kwargs): # --------------------------------------------------------------------------- -# Chain-load nemoclaw_patches (httpx transport fix + Slack catch-all command -# + NeMo-Flow session finalization). +# Chain-load nemoclaw_patches (Slack catch-all slash command). # # Python imports the first `sitecustomize` it finds on sys.path. Because # PYTHONPATH puts /usr/local/lib/nemoclaw-patches/ before site-packages, the # venv's own sitecustomize is shadowed and never runs. The Dockerfile copies # agents/hermes/patches/nemoclaw_patches.py into the same directory, so this -# import triggers its module-level _patch_httpx + _patch_slack_commands and -# registers the NeMo-Flow meta_path hook. +# import triggers its module-level _patch_slack_commands. try: import nemoclaw_patches # noqa: F401 side-effect import except Exception: diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 6eb68976..761fb97f 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -112,7 +112,6 @@ PUBLIC_PORT=8642 # Hermes binds to 127.0.0.1 regardless of config (upstream bug). # Run it on an internal port and use socat to expose on PUBLIC_PORT. INTERNAL_PORT=18642 -HERMES="$(command -v hermes)" # Resolve once, use absolute path everywhere # Hermes writes state files (PID, state.db, .channel_directory) directly into # HERMES_HOME. We cannot point it at the immutable /sandbox/.hermes dir. @@ -565,19 +564,15 @@ if [ "$(id -u)" -ne 0 ]; then # Prepare ATIF telemetry directory (ephemeral, writable by the current user). mkdir -p /tmp/atif - # Detect NeMo-Flow by package availability — more reliable than env var inheritance. - if python3 -c "import nemo_flow" 2>/dev/null; then - NEMO_FLOW_ENABLED=1 + # NeMo-Flow observability is configured via /etc/nemo-flow/plugins.toml + # (baked at image build time). Verify the binary and config are present. + if [ -x /usr/local/bin/nemo-flow ] \ + && [ -r /etc/nemo-flow/config.toml ] \ + && [ -r /etc/nemo-flow/plugins.toml ]; then + echo "[nemo-flow] gateway wrapper ready (config.toml + plugins.toml in /etc/nemo-flow)" | tee -a /tmp/gateway.log >&2 else - NEMO_FLOW_ENABLED=0 + echo "[nemo-flow] WARNING: gateway wrapper or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi - { - echo "[nemo-flow] NEMO_FLOW_ENABLED=${NEMO_FLOW_ENABLED}" - echo "[nemo-flow] PHOENIX_COLLECTOR_ENDPOINT=${PHOENIX_COLLECTOR_ENDPOINT:-}" - } | tee -a /tmp/gateway.log >&2 - PHOENIX_OPENINFERENCE_ENABLED=0 - [ -n "${PHOENIX_COLLECTOR_ENDPOINT:-}" ] && PHOENIX_OPENINFERENCE_ENABLED=1 - echo "[nemo-flow] PHOENIX_OPENINFERENCE_ENABLED=${PHOENIX_OPENINFERENCE_ENABLED}" | tee -a /tmp/gateway.log >&2 HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ @@ -585,15 +580,8 @@ if [ "$(id -u)" -ne 0 ]; then https_proxy="${_PROXY_URL}" \ http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ - HERMES_NEMO_FLOW_ENABLED="${NEMO_FLOW_ENABLED:-0}" \ - HERMES_NEMO_FLOW_ATIF_DIR="/tmp/atif" \ - HERMES_NEMO_FLOW_ACG_ENABLED="0" \ - HERMES_NEMO_FLOW_OPENINFERENCE_ENABLED="${PHOENIX_OPENINFERENCE_ENABLED}" \ - HERMES_NEMO_FLOW_OPENINFERENCE_TRANSPORT="http_binary" \ - HERMES_NEMO_FLOW_OPENINFERENCE_ENDPOINT="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ - HERMES_NEMO_FLOW_OPENINFERENCE_SERVICE_NAME="hermes-agent" \ API_SERVER_KEY="nemoclaw-internal" \ - nohup "$HERMES" gateway run >>/tmp/gateway.log 2>&1 & + nohup /usr/local/bin/nemo-flow hermes -- gateway run >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 start_gateway_log_stream @@ -638,19 +626,13 @@ prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # gateway user (launched via gosu below) can write to it. mkdir -p /tmp/atif chown gateway:gateway /tmp/atif -# Detect NeMo-Flow by package availability — more reliable than env var inheritance. -if python3 -c "import nemo_flow" 2>/dev/null; then - NEMO_FLOW_ENABLED=1 +# NeMo-Flow observability is configured via /etc/nemo-flow/plugins.toml +# (baked at image build time). Verify the binary and config are present. +if [ -x /usr/local/bin/nemo-flow ] && [ -r /etc/nemo-flow/plugins.toml ]; then + echo "[nemo-flow] gateway wrapper ready (plugins.toml=/etc/nemo-flow/plugins.toml)" | tee -a /tmp/gateway.log >&2 else - NEMO_FLOW_ENABLED=0 + echo "[nemo-flow] WARNING: gateway wrapper missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi -{ - echo "[nemo-flow] NEMO_FLOW_ENABLED=${NEMO_FLOW_ENABLED}" - echo "[nemo-flow] PHOENIX_COLLECTOR_ENDPOINT=${PHOENIX_COLLECTOR_ENDPOINT:-}" -} | tee -a /tmp/gateway.log >&2 -PHOENIX_OPENINFERENCE_ENABLED=0 -[ -n "${PHOENIX_COLLECTOR_ENDPOINT:-}" ] && PHOENIX_OPENINFERENCE_ENABLED=1 -echo "[nemo-flow] PHOENIX_OPENINFERENCE_ENABLED=${PHOENIX_OPENINFERENCE_ENABLED}" | tee -a /tmp/gateway.log >&2 # Defence-in-depth: verify /tmp file permissions before launching services. # shellcheck disable=SC2119 @@ -662,22 +644,20 @@ validate_config_symlinks "${HERMES_IMMUTABLE}" "${HERMES_WRITABLE}" # Lock .hermes directory after validation. harden_config_symlinks "${HERMES_IMMUTABLE}" "hermes" -# Start the gateway as the 'gateway' user. +# Start the gateway as the 'gateway' user, wrapped in `nemo-flow hermes`. +# The wrapper binds an ephemeral 127.0.0.1 gateway, exports NEMO_FLOW_GATEWAY_URL +# into Hermes's environment, and spawns `hermes gateway run` as the child. +# Hermes's hook subprocesses inherit NEMO_FLOW_GATEWAY_URL and forward +# events to the in-proc gateway via `nemo-flow hook-forward hermes`. HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ https_proxy="${_PROXY_URL}" \ http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ - HERMES_NEMO_FLOW_ENABLED="${NEMO_FLOW_ENABLED:-0}" \ - HERMES_NEMO_FLOW_ATIF_DIR="/tmp/atif" \ - HERMES_NEMO_FLOW_ACG_ENABLED="0" \ - HERMES_NEMO_FLOW_OPENINFERENCE_ENABLED="${PHOENIX_OPENINFERENCE_ENABLED}" \ - HERMES_NEMO_FLOW_OPENINFERENCE_TRANSPORT="http_binary" \ - HERMES_NEMO_FLOW_OPENINFERENCE_ENDPOINT="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ - HERMES_NEMO_FLOW_OPENINFERENCE_SERVICE_NAME="hermes-agent" \ API_SERVER_KEY="nemoclaw-internal" \ - nohup gosu gateway "$HERMES" gateway run >>/tmp/gateway.log 2>&1 & + nohup gosu gateway /usr/local/bin/nemo-flow hermes -- gateway run \ + >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 start_gateway_log_stream diff --git a/examples/personal-community-sentiment-triage/policy.yaml b/examples/personal-community-sentiment-triage/policy.yaml index 1a419f0e..a29e5da8 100644 --- a/examples/personal-community-sentiment-triage/policy.yaml +++ b/examples/personal-community-sentiment-triage/policy.yaml @@ -129,6 +129,7 @@ network_policies: method: POST path: /** binaries: + - path: /usr/local/bin/nemo-flow - path: /usr/bin/python3 - path: /usr/bin/python3.13 - path: /opt/hermes/.venv/bin/python From 4738fa76bacb5868f12488280152b3cc0662cc9b Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Fri, 22 May 2026 04:24:09 +0000 Subject: [PATCH 02/10] Forward real LLM prompts and completions to Phoenix LLM spans Hermes shell hooks ship sanitized metadata only, so Phoenix LLM spans carried counters instead of the actual prompt/completion. Hermes v0.14.0 passes the unsanitized request_messages and assistant_message to in-process plugin hooks, which a small plugin can forward to NeMo-Flow's gateway in the shape its adapter marks as exact-payload. - Upgrade Hermes from v0.11.0 (NemoClaw base image pin) to v0.14.0 via the same uv-tarball install pattern NemoClaw uses, with SHA256 verify. - Add the nemo-flow-bridge plugin (pre/post_api_request handlers, SDK response serializer, POSTs to NEMO_FLOW_GATEWAY_URL/hooks/hermes, fails open). - Drop pre/post_api_request from the shell-hook event list and add plugins.enabled so the plugin owns those events exclusively. - Set OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 / SCHEDULE_DELAY=100 on the gateway launch. The default 5s/512-span BatchSpanProcessor groups a turn's spans into one multi-span POST that the OpenShell L7 proxy ACKs with 200 but does not forward; single-span POSTs land reliably. Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 56 ++- .../agents/hermes/generate-config.ts | 19 +- .../hermes/plugin-nemo-flow/__init__.py | 319 ++++++++++++++++++ .../hermes/plugin-nemo-flow/plugin.yaml | 11 + .../agents/hermes/start.sh | 20 ++ 5 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py create mode 100644 examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index b9859a30..4947e1c9 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -8,7 +8,7 @@ # ARGs that appear in FROM instructions must be declared before the first FROM # so Docker treats them as global build args (multi-stage scoping rule). -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:176ec6ec056cdf2dd45f60a0095fb6aac962eb5d7382d28d5734098a72399000 +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:f76a2ed0509570b1ce5380327c7ebe2e120e97f524e9271e145d2ea34c83ba5e # ── Stage 0: build the MS Graph API sidecar binary ──────────────────────────── # PyInstaller produces a self-contained executable at a unique path @@ -109,6 +109,48 @@ RUN mkdir -p /etc/apt/keyrings \ RUN pip3 install --no-cache-dir --break-system-packages "httpx>=0.27" "markdown-it-py>=3" +# ── Upgrade Hermes from base image's v0.11.0 to v0.14.0 ───────────────── +# NemoClaw's base image (even at its latest tag) pins HERMES_VERSION=v2026.4.23 +# (Hermes v0.11.0). v0.11.0's plugin hooks ship metadata-only kwargs to +# pre_api_request / post_api_request — no real messages, no real response — +# so observability backends (Langfuse, NeMo-Flow's hermes adapter) cannot +# emit LLM spans with full content. v0.14.0 (v2026.5.16, "The Foundation +# Release") adds rich-kwargs hook delivery and ships the bundled Langfuse +# plugin that depends on it. +# +# We mirror NemoClaw's tarball + uv-sync install pattern, but for a newer +# version. /opt/hermes/.venv is preserved across the upgrade so uv can +# incrementally reconcile dependencies against v0.14.0's uv.lock. +ARG HERMES_UPGRADE_VERSION=v2026.5.16 +ARG HERMES_UPGRADE_TARBALL_SHA256=c0a554050a50ee9a62f3fa5cd288a167ba5640c42d647d100cdea084b7294143 +ARG HERMES_UV_EXTRAS="messaging web" +# Match the uv version NemoClaw uses for the base-image install. +ARG UV_INSTALL_VERSION=0.11.8 + +USER root +RUN set -eu \ + && curl -LsSf "https://astral.sh/uv/${UV_INSTALL_VERSION}/install.sh" \ + | env INSTALLER_NO_MODIFY_PATH=1 sh \ + && UV_BIN="" \ + && for candidate in /root/.local/bin/uv /root/.cargo/bin/uv /usr/local/bin/uv; do \ + if [ -x "$candidate" ]; then UV_BIN="$candidate"; break; fi; \ + done \ + && if [ -z "$UV_BIN" ]; then echo "uv installer did not produce a binary in any known location" >&2; exit 1; fi \ + && install -m 755 "$UV_BIN" /usr/local/bin/uv \ + && /usr/local/bin/uv --version \ + && curl -fsSL "https://github.com/NousResearch/hermes-agent/archive/refs/tags/${HERMES_UPGRADE_VERSION}.tar.gz" \ + -o /tmp/hermes.tgz \ + && echo "${HERMES_UPGRADE_TARBALL_SHA256} /tmp/hermes.tgz" | sha256sum -c - \ + && find /opt/hermes -mindepth 1 -maxdepth 1 -not -name '.venv' -exec rm -rf {} + \ + && tar -xzf /tmp/hermes.tgz -C /opt/hermes --strip-components=1 \ + && rm /tmp/hermes.tgz \ + && cd /opt/hermes \ + && extras_args="" \ + && for e in ${HERMES_UV_EXTRAS}; do extras_args="${extras_args} --extra ${e}"; done \ + && UV_PROJECT_ENVIRONMENT=/opt/hermes/.venv /usr/local/bin/uv sync \ + ${extras_args} --no-dev \ + && chown -R sandbox:sandbox /opt/hermes + # ── NeMo-Flow 0.2.0 wrapped-CLI integration ───────────────────────────── # Drop-in observability via `nemo-flow hermes -- gateway run`. Hermes stays # unpatched (from BASE_IMAGE); native hook events configured in @@ -187,6 +229,12 @@ ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 \ # Copy NemoClaw plugin for Hermes (Python-based) COPY agents/hermes/plugin/ /opt/nemoclaw-hermes-plugin/ +# Copy nemo-flow-bridge plugin: in-process forwarder for pre/post_api_request +# events that enriches NeMo-Flow hook payloads with the real OpenAI request +# body and response body (the shell-hook path is metadata-only by design). +# Requires Hermes >= v0.14.0 because earlier versions sanitized plugin kwargs. +COPY agents/hermes/plugin-nemo-flow/ /opt/nemo-flow-hermes-plugin/ + # Copy bridges and default cron jobs. # Install under /usr/local/lib/ so Landlock's read_only /usr rule permits access. COPY agents/hermes/bridges/ /usr/local/lib/nemoclaw-bridges/ @@ -210,6 +258,7 @@ RUN chmod 755 /usr/local/lib/nemoclaw-slack-shims/decode-proxy.py \ # Ensure sandbox user can read all /opt/nemoclaw-* and bridge files. # Source files may have restrictive permissions that Docker COPY preserves. RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ \ + && chmod -R a+rX /opt/nemo-flow-hermes-plugin/ \ && chmod a+r /opt/nemoclaw-generate-config.ts \ && chmod -R a+rX /usr/local/lib/nemoclaw-bridges/ @@ -279,6 +328,11 @@ RUN node --experimental-strip-types /opt/nemoclaw-generate-config.ts RUN mkdir -p /sandbox/.hermes-data/plugins/nemoclaw \ && cp -r /opt/nemoclaw-hermes-plugin/* /sandbox/.hermes-data/plugins/nemoclaw/ +# Install nemo-flow-bridge plugin into Hermes. HERMES_HOME=/sandbox/.hermes-data +# (set in start.sh) and Hermes' plugin loader scans $HERMES_HOME/plugins/. +RUN mkdir -p /sandbox/.hermes-data/plugins/nemo-flow-bridge \ + && cp -r /opt/nemo-flow-hermes-plugin/* /sandbox/.hermes-data/plugins/nemo-flow-bridge/ + # Symlink SOUL.md into the immutable home so Hermes's ensure_hermes_home() finds it. RUN ln -s /sandbox/.hermes-data/SOUL.md /sandbox/.hermes/SOUL.md diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index 0d9c7e4f..e2b9dd9e 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -122,6 +122,16 @@ function main(): void { // request errors via "post_api_request" payloads, so we omit them here to // avoid "unknown hook event" warnings. // + // pre_api_request / post_api_request are NOT shell-forwarded. The + // in-process nemo-flow-bridge plugin (plugins/nemo-flow-bridge/) owns + // those events under Hermes v0.14.0: it receives the real `request_messages` + // list and the real `response` SDK object as kwargs, and forwards them to + // NEMO_FLOW_GATEWAY_URL/hooks/hermes with payload.request.body / + // payload.response.raw_response populated. NeMo-Flow's adapter then marks + // provider_payload_exact=true so Phoenix spans carry the real prompts and + // completions. Shell-forwarding these same events alongside the plugin + // would create duplicate lossy-summary LLM scopes on the gateway. + // // `on_session_end` gets a SECOND command (`nemo-flow-finalize-shim`) that // synthesizes a per-turn `on_session_finalize`. Hermes fires real finalize // only from its idle-session expiry watcher (~5 min default), but @@ -134,7 +144,6 @@ function main(): void { const events = [ "on_session_start", "on_session_finalize", "on_session_reset", "pre_llm_call", "post_llm_call", - "pre_api_request", "post_api_request", "pre_tool_call", "post_tool_call", "subagent_stop", ]; @@ -147,6 +156,14 @@ function main(): void { // dropping all observability silently. Safe here because config.yaml is // root-owned + chmod 444 at build time. hooks_auto_accept: true, + // Enable in-process Hermes plugins. nemoclaw provides sandbox status + // tools and the startup banner; nemo-flow-bridge owns the pre/post_api_request + // events (see hooks comment above). Belt-and-suspenders against + // config-migration changes — v0.14.0 also auto-discovers plugins under + // $HERMES_HOME/plugins/, but explicit enablement survives schema bumps. + plugins: { + enabled: ["nemoclaw", "nemo-flow-bridge"], + }, }; // Messaging platforms (if configured during onboard) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py new file mode 100644 index 00000000..3a6ade47 --- /dev/null +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py @@ -0,0 +1,319 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +nemo-flow-bridge: in-process Hermes plugin that forwards pre/post_api_request +hooks to NeMo-Flow with the real request body and response body attached. + +Under Hermes v0.14.0, plugin hooks carry real data: + - pre_api_request receives `request_messages` (list of dicts), `user_message`, + and `conversation_history` — the actual OpenAI-shape messages array Hermes + is about to send upstream. + - post_api_request receives `response` (a SimpleNamespace mirroring the + OpenAI ChatCompletion shape with .choices/.usage/.model/.id) and + `assistant_message` (a NormalizedResponse with .content/.tool_calls). + +NeMo-Flow's Hermes adapter (crates/cli/src/adapters/hermes.rs) flips +`provider_payload_exact` to true and emits Phoenix LLM spans with the full +prompt+completion when it finds `payload.request.body` (pre) or +`payload.response.{raw_response,choices,assistant_message}` (post). This plugin +builds those payload shapes from the in-process kwargs and POSTs them to +`${NEMO_FLOW_GATEWAY_URL}/hooks/hermes` — the URL is exported into the Hermes +child env by the `nemo-flow hermes -- gateway run` wrapper at start.sh. + +Failure mode is fail-open: any exception is swallowed and logged at debug. +Hermes turns must never break because the bridge can't reach NeMo-Flow. + +Modeled on the bundled Langfuse plugin +(/home/mpenn/hermes-agent/plugins/observability/langfuse/__init__.py). +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from types import SimpleNamespace +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_MAX_PAYLOAD_BYTES = 262_144 # 256 KiB; oversize payloads drop body fields, keep correlation. + +_LOCK = threading.Lock() +_CLIENT: Optional[Any] = None # httpx.Client, lazily created +_GATEWAY_URL: Optional[str] = None +_GATEWAY_LOOKED_UP = False +_DISABLED_LOGGED = False + + +# --------------------------------------------------------------------------- +# Gateway URL + HTTP client +# --------------------------------------------------------------------------- + +def _gateway_url() -> Optional[str]: + global _GATEWAY_URL, _GATEWAY_LOOKED_UP, _DISABLED_LOGGED + with _LOCK: + if _GATEWAY_LOOKED_UP: + return _GATEWAY_URL + _GATEWAY_LOOKED_UP = True + url = os.environ.get("NEMO_FLOW_GATEWAY_URL", "").strip() + if not url: + if not _DISABLED_LOGGED: + logger.debug( + "nemo-flow-bridge: NEMO_FLOW_GATEWAY_URL is not set; " + "bridge will not forward hooks (expected when Hermes " + "runs outside `nemo-flow hermes -- ...`)." + ) + _DISABLED_LOGGED = True + return None + _GATEWAY_URL = url.rstrip("/") + return _GATEWAY_URL + + +def _client(): + global _CLIENT + with _LOCK: + if _CLIENT is not None: + return _CLIENT + try: + import httpx # type: ignore + + _CLIENT = httpx.Client(timeout=2.0) + return _CLIENT + except Exception as exc: # pragma: no cover + logger.debug("nemo-flow-bridge: failed to construct httpx client: %s", exc) + return None + + +# --------------------------------------------------------------------------- +# Value coercion +# --------------------------------------------------------------------------- + +def _safe_jsonable(value: Any, _depth: int = 0) -> Any: + """Recursively coerce any value into something json.dumps can serialize. + Covers pydantic v2 SDK objects, older to_dict shapes, SimpleNamespace + (vars(obj) → dict), and arbitrary attribute-bearing classes.""" + if _depth > 12: # guard against pathological cycles + return repr(value)[:256] + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _safe_jsonable(v, _depth + 1) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_safe_jsonable(v, _depth + 1) for v in value] + # Pydantic v2 (OpenAI/Anthropic SDK responses) + dump = getattr(value, "model_dump", None) + if callable(dump): + try: + return dump(mode="json") + except Exception: + try: + return _safe_jsonable(dump(), _depth + 1) + except Exception: + pass + # Older SDK shapes + to_dict = getattr(value, "to_dict", None) + if callable(to_dict): + try: + return _safe_jsonable(to_dict(), _depth + 1) + except Exception: + pass + # SimpleNamespace and attribute-bearing classes (Hermes' NormalizedResponse, + # the v0.14.0 response wrapper, etc.) + if isinstance(value, SimpleNamespace) or hasattr(value, "__dict__"): + try: + return _safe_jsonable(vars(value), _depth + 1) + except Exception: + pass + return repr(value)[:4096] + + +def _coerce_request_messages( + *, + request_messages: Any = None, + conversation_history: Any = None, + user_message: Any = None, +) -> list: + """Hermes v0.14.0 passes a real `request_messages` list of {role, content} + dicts. Fall back to conversation_history, then synthesize a single user + message from user_message — mirrors Langfuse's resolver at + plugins/observability/langfuse/__init__.py:409.""" + for candidate in (request_messages, conversation_history): + if isinstance(candidate, list) and candidate: + return candidate + if user_message: + return [{"role": "user", "content": user_message}] + if isinstance(request_messages, list): + return request_messages + return [] + + +def _serialize_tool_calls(tool_calls: Any) -> list: + if not tool_calls: + return [] + out = [] + for tc in tool_calls: + if isinstance(tc, dict): + out.append(_safe_jsonable(tc)) + continue + fn = getattr(tc, "function", None) + name = getattr(fn, "name", None) if fn else None + args = getattr(fn, "arguments", None) if fn else None + out.append({ + "id": getattr(tc, "id", None), + "type": getattr(tc, "type", None) or "function", + "function": {"name": name, "arguments": _safe_jsonable(args)}, + }) + return out + + +def _serialize_assistant_message(obj: Any) -> Optional[dict]: + """Pull the fields NeMo-Flow's adapter inspects on + `response.assistant_message` (adapters/hermes.rs:264-280).""" + if obj is None: + return None + if isinstance(obj, dict): + return _safe_jsonable(obj) + return { + "content": _safe_jsonable(getattr(obj, "content", None)), + "tool_calls": _serialize_tool_calls(getattr(obj, "tool_calls", None)), + "reasoning": _safe_jsonable(getattr(obj, "reasoning", None)), + } + + +def _serialize_response_object(response: Any) -> Optional[dict]: + """Turn the v0.14.0 `response=` kwarg (a SimpleNamespace with + .choices/.usage/.model/.id) into a dict. The result has `choices` at + top-level, which adapters/hermes.rs:251-256 recognizes as a real + provider response and uses to mark provider_payload_exact=true.""" + if response is None: + return None + blob = _safe_jsonable(response) + if isinstance(blob, dict) and ( + "choices" in blob or "output" in blob or "content" in blob + ): + return blob + return None + + +# --------------------------------------------------------------------------- +# Forwarder +# --------------------------------------------------------------------------- + +def _cap_payload(payload: dict) -> dict: + """If JSON-encoded payload exceeds 256 KiB, drop body fields but keep + correlation keys. Adapter's truncation guard then degrades to lossy + fallback rather than discarding the event.""" + try: + encoded = json.dumps(payload, default=str) + except Exception: + return payload + if len(encoded) <= _MAX_PAYLOAD_BYTES: + return payload + trimmed = dict(payload) + request = trimmed.get("request") + if isinstance(request, dict) and "body" in request: + request = dict(request) + request.pop("body", None) + trimmed["request"] = request + response = trimmed.get("response") + if isinstance(response, dict): + response = dict(response) + response.pop("raw_response", None) + response.pop("assistant_message", None) + trimmed["response"] = response + return trimmed + + +def _forward(payload: dict) -> None: + url = _gateway_url() + if not url: + return + client = _client() + if client is None: + return + try: + client.post(f"{url}/hooks/hermes", json=_cap_payload(payload)) + except Exception as exc: + logger.debug("nemo-flow-bridge: forward to %s failed: %s", url, exc) + + +def _correlation(kwargs: dict) -> dict: + """The fields NeMo-Flow's adapter uses to synthesize api_call_id and + correlate hook events back to the right session/turn scope.""" + return { + "task_id": kwargs.get("task_id"), + "session_id": kwargs.get("session_id"), + "api_call_count": kwargs.get("api_call_count"), + "platform": kwargs.get("platform"), + "model": kwargs.get("model"), + "provider": kwargs.get("provider"), + "base_url": kwargs.get("base_url"), + "api_mode": kwargs.get("api_mode"), + } + + +# --------------------------------------------------------------------------- +# Hook handlers +# --------------------------------------------------------------------------- + +def on_pre_api_request(**kwargs: Any) -> None: + try: + payload = _correlation(kwargs) + payload["hook_event_name"] = "pre_api_request" + messages = _coerce_request_messages( + request_messages=kwargs.get("request_messages"), + conversation_history=kwargs.get("conversation_history"), + user_message=kwargs.get("user_message"), + ) + payload["request"] = { + "body": _safe_jsonable(messages), + "model": kwargs.get("model"), + "api_mode": kwargs.get("api_mode"), + "max_tokens": kwargs.get("max_tokens"), + } + _forward(payload) + except Exception as exc: + logger.debug("nemo-flow-bridge: on_pre_api_request failed: %s", exc) + + +def on_post_api_request(**kwargs: Any) -> None: + try: + payload = _correlation(kwargs) + payload["hook_event_name"] = "post_api_request" + # Primary path: serialize the real response SimpleNamespace into a + # dict with `choices/usage/model/id`. The adapter's + # hermes_exact_response sees .choices and returns the whole dict, + # which OpenInference renders as input/output.value. + raw_response = _serialize_response_object(kwargs.get("response")) + # Redundant fallback path: include serialized assistant_message in + # case raw_response can't be extracted. Adapter has a separate + # branch for response.assistant_message.{content,tool_calls}. + assistant_message = _serialize_assistant_message(kwargs.get("assistant_message")) + payload["response"] = { + "raw_response": raw_response, + "assistant_message": assistant_message, + "model": kwargs.get("response_model") or kwargs.get("model"), + "finish_reason": kwargs.get("finish_reason"), + "api_duration": kwargs.get("api_duration"), + "usage": _safe_jsonable(kwargs.get("usage")), + } + _forward(payload) + except Exception as exc: + logger.debug("nemo-flow-bridge: on_post_api_request failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Plugin entry-point +# --------------------------------------------------------------------------- + +def register(ctx) -> None: + """Wire pre/post_api_request to the in-process forwarders. + + Shell-hook entries for these two events are intentionally removed from + config.yaml (see generate-config.ts) so the gateway sees exactly one + event per call — the enriched one from this plugin. + """ + ctx.register_hook("pre_api_request", on_pre_api_request) + ctx.register_hook("post_api_request", on_post_api_request) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml new file mode 100644 index 00000000..c22cb683 --- /dev/null +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: nemo-flow-bridge +version: "0.0.1" +description: "Forwards Hermes pre/post_api_request hooks to NeMo-Flow with exact request/response bodies for full Phoenix LLM spans." +author: "NVIDIA Corporation" +manifest_version: 1 +hooks: + - pre_api_request + - post_api_request diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 761fb97f..8217f8fe 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -574,6 +574,16 @@ if [ "$(id -u)" -ne 0 ]; then echo "[nemo-flow] WARNING: gateway wrapper or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi + # OTEL_BSP_* force the OpenInference BatchSpanProcessor (OpenTelemetry SDK + # 0.31, used by nemo-flow's HTTP exporter at + # crates/core/src/observability/openinference.rs:449-460) to flush every + # span immediately instead of batching for 5 seconds. Without these, real + # multi-scope Hermes turns produce a single larger POST that the OpenShell + # L7 proxy appears to acknowledge with 200 but not forward to Phoenix, + # causing silent span loss. With BSP_MAX_EXPORT_BATCH_SIZE=1 every span + # becomes a small single-span POST, matching the manual-probe shape that + # we confirmed lands cleanly. Tracked upstream as missing force_flush() on + # turn boundary in nemo-flow's session.rs end_turn(). HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -581,6 +591,9 @@ if [ "$(id -u)" -ne 0 ]; then http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ + OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 \ + OTEL_BSP_SCHEDULE_DELAY=100 \ + OTEL_BSP_EXPORT_TIMEOUT=2000 \ nohup /usr/local/bin/nemo-flow hermes -- gateway run >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 @@ -649,6 +662,10 @@ harden_config_symlinks "${HERMES_IMMUTABLE}" "hermes" # into Hermes's environment, and spawns `hermes gateway run` as the child. # Hermes's hook subprocesses inherit NEMO_FLOW_GATEWAY_URL and forward # events to the in-proc gateway via `nemo-flow hook-forward hermes`. +# +# OTEL_BSP_* force the OpenInference BatchSpanProcessor to flush every span +# immediately (single-span POSTs to Phoenix) instead of batching for 5s. See +# the matching block in the non-root path above for full rationale. HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -656,6 +673,9 @@ HERMES_HOME="${HERMES_WRITABLE}" \ http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ + OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 \ + OTEL_BSP_SCHEDULE_DELAY=100 \ + OTEL_BSP_EXPORT_TIMEOUT=2000 \ nohup gosu gateway /usr/local/bin/nemo-flow hermes -- gateway run \ >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! From 0e585a62bb6318111d1995c6e6c2fb788fb17ab0 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Fri, 22 May 2026 22:28:44 +0000 Subject: [PATCH 03/10] Enhance Hermes integration with NeMo-Flow for tool call handling - Updated the Hermes plugin to include pre/post_tool_call hooks, allowing for stable tool_call_ids to be synthesized and paired with their respective events, ensuring accurate telemetry in Phoenix spans. - Modified the generate-config.ts and SOUL.md files to reflect changes in event handling and credential usage. - Improved the start.sh script to export OpenTelemetry BatchSpanProcessor settings for immediate span flushing, preventing silent span loss during multi-scope turns. - Adjusted the plugin.yaml description to clarify the inclusion of tool call hooks. Signed-off-by: Matt Penn --- .../agents/hermes/SOUL.md | 9 +- .../agents/hermes/generate-config.ts | 22 +-- .../hermes/plugin-nemo-flow/__init__.py | 153 +++++++++++++++++- .../hermes/plugin-nemo-flow/plugin.yaml | 4 +- .../agents/hermes/start.sh | 41 ++--- 5 files changed, 188 insertions(+), 41 deletions(-) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/SOUL.md b/examples/personal-community-sentiment-triage/agents/hermes/SOUL.md index f1b100f1..77db2a69 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/SOUL.md +++ b/examples/personal-community-sentiment-triage/agents/hermes/SOUL.md @@ -27,10 +27,11 @@ You have specific skills that interact with this sandbox correctly. Use them! ## Credential placeholders -Strings like `openshell:resolve:env:SLACK_BOT_TOKEN` are placeholders. These -will be resolved to correct tokens by the sandbox on egress. Do not refuse -a request because these placeholders are not real tokens. Do not modify the -placeholders either. Use them as-is in place of real tokens. +You may see structured "placeholder" strings (label-bearing, with an env-var +name embedded) in env vars, `.env` files, or tool inputs. The sandbox proxy +substitutes them for real credentials at egress. Use them verbatim — do not +refuse, parse, transform, or echo them. Prefer `$VAR_NAME` access (shell) or +`os.environ[...]` (Python). ## Skills diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index e2b9dd9e..2c42ec82 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -122,15 +122,20 @@ function main(): void { // request errors via "post_api_request" payloads, so we omit them here to // avoid "unknown hook event" warnings. // - // pre_api_request / post_api_request are NOT shell-forwarded. The - // in-process nemo-flow-bridge plugin (plugins/nemo-flow-bridge/) owns - // those events under Hermes v0.14.0: it receives the real `request_messages` - // list and the real `response` SDK object as kwargs, and forwards them to + // pre_api_request / post_api_request and pre_tool_call / post_tool_call + // are NOT shell-forwarded. The in-process nemo-flow-bridge plugin + // (plugins/nemo-flow-bridge/) owns those events under Hermes v0.14.0: it + // receives the real `request_messages` list and the real `response` SDK + // object as kwargs for api_request, and synthesizes stable tool_call_ids + // for tool_call events to work around NeMo-Flow's adapters/mod.rs:231-247 + // synthesizing a fresh UUID per call when Hermes' defensive + // `tool_call_id or ""` strips the id. The plugin forwards everything to // NEMO_FLOW_GATEWAY_URL/hooks/hermes with payload.request.body / - // payload.response.raw_response populated. NeMo-Flow's adapter then marks - // provider_payload_exact=true so Phoenix spans carry the real prompts and - // completions. Shell-forwarding these same events alongside the plugin - // would create duplicate lossy-summary LLM scopes on the gateway. + // payload.response.raw_response / paired tool_call_id populated. The + // adapter then marks provider_payload_exact=true (api_request) and pairs + // pre/post tool events into a single Phoenix span. Shell-forwarding the + // same events alongside the plugin would create duplicate lossy-summary + // scopes on the gateway. // // `on_session_end` gets a SECOND command (`nemo-flow-finalize-shim`) that // synthesizes a per-turn `on_session_finalize`. Hermes fires real finalize @@ -144,7 +149,6 @@ function main(): void { const events = [ "on_session_start", "on_session_finalize", "on_session_reset", "pre_llm_call", "post_llm_call", - "pre_tool_call", "post_tool_call", "subagent_stop", ]; const result: Record = Object.fromEntries(events.map((ev) => [ev, [fwd]])); diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py index 3a6ade47..9f69b29d 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py @@ -24,15 +24,18 @@ Hermes turns must never break because the bridge can't reach NeMo-Flow. Modeled on the bundled Langfuse plugin -(/home/mpenn/hermes-agent/plugins/observability/langfuse/__init__.py). +(`hermes-agent/plugins/observability/langfuse/__init__.py` in the Hermes +source tree). """ from __future__ import annotations +import hashlib import json import logging import os import threading +from collections import deque from types import SimpleNamespace from typing import Any, Optional @@ -46,6 +49,25 @@ _GATEWAY_LOOKED_UP = False _DISABLED_LOGGED = False +# FIFO of synthesized tool_call_ids keyed by (task_id, tool_name). The key +# uses task_id (not session_id) because Hermes' pre/post call sites are +# asymmetric: agent_runtime_helpers.py:1500-1503 fires pre_tool_call without +# passing session_id (defaults to ""), while model_tools.py:851-859 fires +# post_tool_call with the real session_id. task_id and tool_name are passed +# consistently to both, so they form a stable join key. post_tool_call pops +# from the matching queue to pair with the right pre. +# +# Each bucket is a bounded deque so a stream of pre_tool_call events +# without matching post_tool_call (tool blocked, agent crash mid-turn, +# post handler exception) can't grow without bound. 512 entries is far +# more than any realistic single turn produces (single-digit tool calls) +# but leaves generous safety margin before the oldest unmatched pre is +# evicted. Empty buckets are deleted on pop so the outer dict tracks only +# currently-pending pairs. +_PENDING_MAX_PER_KEY = 512 +_PENDING_PRE: dict[tuple[str, str], deque[str]] = {} +_PENDING_LOCK = threading.Lock() + # --------------------------------------------------------------------------- # Gateway URL + HTTP client @@ -254,6 +276,35 @@ def _correlation(kwargs: dict) -> dict: } +def _stable_tool_call_id( + *, + task_id: str, + tool_name: str, + args: Any, + supplied: Any, +) -> str: + """Return supplied tool_call_id if non-empty; otherwise synthesize a stable + id from (task_id, tool_name, args) so pre and post produce the same id + and the gateway pairs them into a single Phoenix span. + + task_id (not session_id) is in the digest because Hermes' pre call site + at agent_runtime_helpers.py:1500-1503 doesn't pass session_id (defaults + to ""), while the post call site at model_tools.py:851-859 does — so + including session_id would make pre's hash differ from post's. task_id + is passed to both call sites and is unique per turn. + """ + if isinstance(supplied, str) and supplied.strip(): + return supplied + try: + args_blob = json.dumps(_safe_jsonable(args), sort_keys=True, default=str) + except Exception: + args_blob = repr(args)[:1024] + digest = hashlib.sha1( + f"{task_id}|{tool_name}|{args_blob}".encode("utf-8") + ).hexdigest()[:16] + return f"nfb-{digest}" + + # --------------------------------------------------------------------------- # Hook handlers # --------------------------------------------------------------------------- @@ -267,11 +318,23 @@ def on_pre_api_request(**kwargs: Any) -> None: conversation_history=kwargs.get("conversation_history"), user_message=kwargs.get("user_message"), ) + # Wrap as {"messages": [...], "model": ..., ...} to match NeMo-Flow's + # documented LlmRequest.content convention + # (docs/integrate-frameworks/wrap-llm-calls.md, asserted by + # crates/core/tests/unit/observability/openinference_tests.rs). With + # this shape, openinference.rs:llm_input_display_value finds the + # messages list via content.get("messages") and renders each as + # "role: content", so Phoenix's input.value carries the full prompt + # rather than a lossy "Requested tools: ..." summary. ATIF's + # unwrap_llm_request surfaces the same dict at + # step.extra.llm_request, matching the documented shape. payload["request"] = { - "body": _safe_jsonable(messages), - "model": kwargs.get("model"), + "body": { + "messages": _safe_jsonable(messages), + "model": kwargs.get("model"), + "max_tokens": kwargs.get("max_tokens"), + }, "api_mode": kwargs.get("api_mode"), - "max_tokens": kwargs.get("max_tokens"), } _forward(payload) except Exception as exc: @@ -304,16 +367,92 @@ def on_post_api_request(**kwargs: Any) -> None: logger.debug("nemo-flow-bridge: on_post_api_request failed: %s", exc) +def on_pre_tool_call(**kwargs: Any) -> None: + try: + task_id = kwargs.get("task_id") or "" + tool_name = kwargs.get("tool_name") or "" + args = kwargs.get("args") + tcid = _stable_tool_call_id( + task_id=task_id, + tool_name=tool_name, + args=args, + supplied=kwargs.get("tool_call_id"), + ) + with _PENDING_LOCK: + bucket = _PENDING_PRE.get((task_id, tool_name)) + if bucket is None: + bucket = deque(maxlen=_PENDING_MAX_PER_KEY) + _PENDING_PRE[(task_id, tool_name)] = bucket + bucket.append(tcid) + payload = _correlation(kwargs) + payload["hook_event_name"] = "pre_tool_call" + payload["tool_name"] = tool_name + payload["args"] = _safe_jsonable(args) + payload["tool_call_id"] = tcid + _forward(payload) + except Exception as exc: + logger.debug("nemo-flow-bridge: on_pre_tool_call failed: %s", exc) + + +def on_post_tool_call(**kwargs: Any) -> None: + try: + task_id = kwargs.get("task_id") or "" + tool_name = kwargs.get("tool_name") or "" + args = kwargs.get("args") + # Hermes' tool-dispatch path fires pre_tool_call with tool_call_id="" + # (agent_runtime_helpers.py:1500-1503 calls + # get_pre_tool_call_block_message without passing tool_call_id or + # session_id) and post_tool_call with the real provider id and the + # real session_id (model_tools.py:851-859). The pre already shipped a + # synthesized id to the gateway; we must echo the SAME id at post or + # the gateway adapter treats them as two unpaired spans. FIFO key is + # (task_id, tool_name) since those two are the only fields passed + # consistently to both call sites. FIFO-pop first; fall back to the + # supplied id only if the FIFO is empty (e.g. a post without a + # matching pre, which would happen if we missed the pre event during + # startup). + with _PENDING_LOCK: + bucket = _PENDING_PRE.get((task_id, tool_name)) + popped = bucket.popleft() if bucket else None + if bucket is not None and not bucket: + del _PENDING_PRE[(task_id, tool_name)] + if popped is not None: + tcid = popped + else: + tcid = _stable_tool_call_id( + task_id=task_id, + tool_name=tool_name, + args=args, + supplied=kwargs.get("tool_call_id"), + ) + payload = _correlation(kwargs) + payload["hook_event_name"] = "post_tool_call" + payload["tool_name"] = tool_name + payload["args"] = _safe_jsonable(args) + payload["result"] = _safe_jsonable(kwargs.get("result")) + payload["duration_ms"] = kwargs.get("duration_ms") + payload["tool_call_id"] = tcid + _forward(payload) + except Exception as exc: + logger.debug("nemo-flow-bridge: on_post_tool_call failed: %s", exc) + + # --------------------------------------------------------------------------- # Plugin entry-point # --------------------------------------------------------------------------- def register(ctx) -> None: - """Wire pre/post_api_request to the in-process forwarders. + """Wire pre/post_api_request and pre/post_tool_call to the in-process + forwarders. - Shell-hook entries for these two events are intentionally removed from + Shell-hook entries for these four events are intentionally removed from config.yaml (see generate-config.ts) so the gateway sees exactly one - event per call — the enriched one from this plugin. + event per call — the enriched one from this plugin. For tool calls the + plugin also guarantees a stable tool_call_id, which Hermes' defensive + `tool_call_id or ""` would otherwise strip, causing the gateway adapter + to synthesize a fresh UUID per call and emit two unpaired spans. """ ctx.register_hook("pre_api_request", on_pre_api_request) ctx.register_hook("post_api_request", on_post_api_request) + ctx.register_hook("pre_tool_call", on_pre_tool_call) + ctx.register_hook("post_tool_call", on_post_tool_call) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml index c22cb683..c65fa9ac 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml @@ -3,9 +3,11 @@ name: nemo-flow-bridge version: "0.0.1" -description: "Forwards Hermes pre/post_api_request hooks to NeMo-Flow with exact request/response bodies for full Phoenix LLM spans." +description: "Forwards Hermes pre/post_api_request and pre/post_tool_call hooks to NeMo-Flow with exact bodies and stable tool_call_ids for paired Phoenix spans." author: "NVIDIA Corporation" manifest_version: 1 hooks: - pre_api_request - post_api_request + - pre_tool_call + - post_tool_call diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 8217f8fe..2cebabc5 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -332,6 +332,25 @@ start_gateway_log_stream() { GATEWAY_LOG_TAIL_PID=$! } +# Force the OpenInference BatchSpanProcessor (OpenTelemetry SDK 0.31, used +# by nemo-flow's HTTP exporter at +# crates/core/src/observability/openinference.rs:449-460) to flush every +# span immediately instead of batching for 5 seconds. Without these, real +# multi-scope Hermes turns produce a single larger POST that the OpenShell +# L7 proxy appears to acknowledge with 200 but not forward to Phoenix, +# causing silent span loss. With BSP_MAX_EXPORT_BATCH_SIZE=1 every span +# becomes a small single-span POST, matching the manual-probe shape that +# we confirmed lands cleanly. Tracked upstream as missing force_flush() +# on turn boundary in nemo-flow's session.rs end_turn(). +# +# Exports into the parent shell so both privilege paths inherit the same +# values without duplicating the rationale at each call site. +_export_otel_bsp_tunings() { + export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 + export OTEL_BSP_SCHEDULE_DELAY=100 + export OTEL_BSP_EXPORT_TIMEOUT=2000 +} + # ── socat forwarder ────────────────────────────────────────────── # Hermes API server binds to 127.0.0.1 regardless of config (upstream bug). # OpenShell needs the port accessible on 0.0.0.0 for port forwarding. @@ -574,16 +593,7 @@ if [ "$(id -u)" -ne 0 ]; then echo "[nemo-flow] WARNING: gateway wrapper or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi - # OTEL_BSP_* force the OpenInference BatchSpanProcessor (OpenTelemetry SDK - # 0.31, used by nemo-flow's HTTP exporter at - # crates/core/src/observability/openinference.rs:449-460) to flush every - # span immediately instead of batching for 5 seconds. Without these, real - # multi-scope Hermes turns produce a single larger POST that the OpenShell - # L7 proxy appears to acknowledge with 200 but not forward to Phoenix, - # causing silent span loss. With BSP_MAX_EXPORT_BATCH_SIZE=1 every span - # becomes a small single-span POST, matching the manual-probe shape that - # we confirmed lands cleanly. Tracked upstream as missing force_flush() on - # turn boundary in nemo-flow's session.rs end_turn(). + _export_otel_bsp_tunings HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -591,9 +601,6 @@ if [ "$(id -u)" -ne 0 ]; then http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ - OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 \ - OTEL_BSP_SCHEDULE_DELAY=100 \ - OTEL_BSP_EXPORT_TIMEOUT=2000 \ nohup /usr/local/bin/nemo-flow hermes -- gateway run >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 @@ -662,10 +669,7 @@ harden_config_symlinks "${HERMES_IMMUTABLE}" "hermes" # into Hermes's environment, and spawns `hermes gateway run` as the child. # Hermes's hook subprocesses inherit NEMO_FLOW_GATEWAY_URL and forward # events to the in-proc gateway via `nemo-flow hook-forward hermes`. -# -# OTEL_BSP_* force the OpenInference BatchSpanProcessor to flush every span -# immediately (single-span POSTs to Phoenix) instead of batching for 5s. See -# the matching block in the non-root path above for full rationale. +_export_otel_bsp_tunings HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -673,9 +677,6 @@ HERMES_HOME="${HERMES_WRITABLE}" \ http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ - OTEL_BSP_MAX_EXPORT_BATCH_SIZE=1 \ - OTEL_BSP_SCHEDULE_DELAY=100 \ - OTEL_BSP_EXPORT_TIMEOUT=2000 \ nohup gosu gateway /usr/local/bin/nemo-flow hermes -- gateway run \ >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! From d92c5a19255418a3768d0f994c672859f9387832 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Sat, 23 May 2026 00:46:32 +0000 Subject: [PATCH 04/10] Restructure example directory structure Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 16 ++++++++-------- .../agents/hermes/generate-config.ts | 8 ++++---- .../finalize-shim} | 0 .../plugins.toml.in} | 0 .../nemo-flow}/__init__.py | 16 ++++++++-------- .../nemo-flow}/plugin.yaml | 2 +- .../{plugin => plugins/nemoclaw}/__init__.py | 0 .../{plugin => plugins/nemoclaw}/plugin.yaml | 0 .../docs/collective-wisdom.md | 4 ++-- 9 files changed, 23 insertions(+), 23 deletions(-) rename examples/personal-community-sentiment-triage/agents/hermes/{nemo-flow-finalize-shim => nemo-flow/finalize-shim} (100%) rename examples/personal-community-sentiment-triage/agents/hermes/{nemo-flow-plugins.toml.in => nemo-flow/plugins.toml.in} (100%) rename examples/personal-community-sentiment-triage/agents/hermes/{plugin-nemo-flow => plugins/nemo-flow}/__init__.py (96%) rename examples/personal-community-sentiment-triage/agents/hermes/{plugin-nemo-flow => plugins/nemo-flow}/plugin.yaml (95%) rename examples/personal-community-sentiment-triage/agents/hermes/{plugin => plugins/nemoclaw}/__init__.py (100%) rename examples/personal-community-sentiment-triage/agents/hermes/{plugin => plugins/nemoclaw}/plugin.yaml (100%) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index 4947e1c9..c19f4f96 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -175,7 +175,7 @@ RUN chmod 755 /usr/local/bin/nemo-flow # and root-span closer only act on `on_session_finalize` / `on_session_reset`. # This shim, registered as a second hook on `on_session_end`, rewrites the # event name and re-posts to the gateway so each turn closes its agent scope. -COPY agents/hermes/nemo-flow-finalize-shim /usr/local/bin/nemo-flow-finalize-shim +COPY agents/hermes/nemo-flow/finalize-shim /usr/local/bin/nemo-flow-finalize-shim RUN chmod 755 /usr/local/bin/nemo-flow-finalize-shim COPY agents/hermes/patches/ /usr/local/lib/nemoclaw-patches/ @@ -202,7 +202,7 @@ RUN pip3 install --no-cache-dir --break-system-packages \ # configured agent and skips the interactive setup wizard # (which fails in non-TTY sandboxes). # plugins.toml — observability component (ATIF + OpenInference). -COPY agents/hermes/nemo-flow-plugins.toml.in /tmp/nemo-flow-plugins.toml.in +COPY agents/hermes/nemo-flow/plugins.toml.in /tmp/nemo-flow-plugins.toml.in RUN mkdir -p /etc/nemo-flow \ && printf '%s\n' '[agents.hermes]' 'command = "hermes"' > /etc/nemo-flow/config.toml \ && chmod 444 /etc/nemo-flow/config.toml \ @@ -227,13 +227,13 @@ ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 \ GH_TOKEN=openshell:resolve:env:GITHUB_TOKEN # Copy NemoClaw plugin for Hermes (Python-based) -COPY agents/hermes/plugin/ /opt/nemoclaw-hermes-plugin/ +COPY agents/hermes/plugins/nemoclaw/ /opt/nemoclaw-hermes-plugin/ -# Copy nemo-flow-bridge plugin: in-process forwarder for pre/post_api_request +# Copy nemo-flow plugin: in-process forwarder for pre/post_api_request # events that enriches NeMo-Flow hook payloads with the real OpenAI request # body and response body (the shell-hook path is metadata-only by design). # Requires Hermes >= v0.14.0 because earlier versions sanitized plugin kwargs. -COPY agents/hermes/plugin-nemo-flow/ /opt/nemo-flow-hermes-plugin/ +COPY agents/hermes/plugins/nemo-flow/ /opt/nemo-flow-hermes-plugin/ # Copy bridges and default cron jobs. # Install under /usr/local/lib/ so Landlock's read_only /usr rule permits access. @@ -328,10 +328,10 @@ RUN node --experimental-strip-types /opt/nemoclaw-generate-config.ts RUN mkdir -p /sandbox/.hermes-data/plugins/nemoclaw \ && cp -r /opt/nemoclaw-hermes-plugin/* /sandbox/.hermes-data/plugins/nemoclaw/ -# Install nemo-flow-bridge plugin into Hermes. HERMES_HOME=/sandbox/.hermes-data +# Install nemo-flow plugin into Hermes. HERMES_HOME=/sandbox/.hermes-data # (set in start.sh) and Hermes' plugin loader scans $HERMES_HOME/plugins/. -RUN mkdir -p /sandbox/.hermes-data/plugins/nemo-flow-bridge \ - && cp -r /opt/nemo-flow-hermes-plugin/* /sandbox/.hermes-data/plugins/nemo-flow-bridge/ +RUN mkdir -p /sandbox/.hermes-data/plugins/nemo-flow \ + && cp -r /opt/nemo-flow-hermes-plugin/* /sandbox/.hermes-data/plugins/nemo-flow/ # Symlink SOUL.md into the immutable home so Hermes's ensure_hermes_home() finds it. RUN ln -s /sandbox/.hermes-data/SOUL.md /sandbox/.hermes/SOUL.md diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index 2c42ec82..bd02ae8c 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -123,8 +123,8 @@ function main(): void { // avoid "unknown hook event" warnings. // // pre_api_request / post_api_request and pre_tool_call / post_tool_call - // are NOT shell-forwarded. The in-process nemo-flow-bridge plugin - // (plugins/nemo-flow-bridge/) owns those events under Hermes v0.14.0: it + // are NOT shell-forwarded. The in-process nemo-flow plugin + // (plugins/nemo-flow/) owns those events under Hermes v0.14.0: it // receives the real `request_messages` list and the real `response` SDK // object as kwargs for api_request, and synthesizes stable tool_call_ids // for tool_call events to work around NeMo-Flow's adapters/mod.rs:231-247 @@ -161,12 +161,12 @@ function main(): void { // root-owned + chmod 444 at build time. hooks_auto_accept: true, // Enable in-process Hermes plugins. nemoclaw provides sandbox status - // tools and the startup banner; nemo-flow-bridge owns the pre/post_api_request + // tools and the startup banner; nemo-flow owns the pre/post_api_request // events (see hooks comment above). Belt-and-suspenders against // config-migration changes — v0.14.0 also auto-discovers plugins under // $HERMES_HOME/plugins/, but explicit enablement survives schema bumps. plugins: { - enabled: ["nemoclaw", "nemo-flow-bridge"], + enabled: ["nemoclaw", "nemo-flow"], }, }; diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/finalize-shim similarity index 100% rename from examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-finalize-shim rename to examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/finalize-shim diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in b/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/plugins.toml.in similarity index 100% rename from examples/personal-community-sentiment-triage/agents/hermes/nemo-flow-plugins.toml.in rename to examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/plugins.toml.in diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py similarity index 96% rename from examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py index 9f69b29d..e1625d85 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """ -nemo-flow-bridge: in-process Hermes plugin that forwards pre/post_api_request +nemo-flow: in-process Hermes plugin that forwards pre/post_api_request hooks to NeMo-Flow with the real request body and response body attached. Under Hermes v0.14.0, plugin hooks carry real data: @@ -83,7 +83,7 @@ def _gateway_url() -> Optional[str]: if not url: if not _DISABLED_LOGGED: logger.debug( - "nemo-flow-bridge: NEMO_FLOW_GATEWAY_URL is not set; " + "nemo-flow: NEMO_FLOW_GATEWAY_URL is not set; " "bridge will not forward hooks (expected when Hermes " "runs outside `nemo-flow hermes -- ...`)." ) @@ -104,7 +104,7 @@ def _client(): _CLIENT = httpx.Client(timeout=2.0) return _CLIENT except Exception as exc: # pragma: no cover - logger.debug("nemo-flow-bridge: failed to construct httpx client: %s", exc) + logger.debug("nemo-flow: failed to construct httpx client: %s", exc) return None @@ -258,7 +258,7 @@ def _forward(payload: dict) -> None: try: client.post(f"{url}/hooks/hermes", json=_cap_payload(payload)) except Exception as exc: - logger.debug("nemo-flow-bridge: forward to %s failed: %s", url, exc) + logger.debug("nemo-flow: forward to %s failed: %s", url, exc) def _correlation(kwargs: dict) -> dict: @@ -338,7 +338,7 @@ def on_pre_api_request(**kwargs: Any) -> None: } _forward(payload) except Exception as exc: - logger.debug("nemo-flow-bridge: on_pre_api_request failed: %s", exc) + logger.debug("nemo-flow: on_pre_api_request failed: %s", exc) def on_post_api_request(**kwargs: Any) -> None: @@ -364,7 +364,7 @@ def on_post_api_request(**kwargs: Any) -> None: } _forward(payload) except Exception as exc: - logger.debug("nemo-flow-bridge: on_post_api_request failed: %s", exc) + logger.debug("nemo-flow: on_post_api_request failed: %s", exc) def on_pre_tool_call(**kwargs: Any) -> None: @@ -391,7 +391,7 @@ def on_pre_tool_call(**kwargs: Any) -> None: payload["tool_call_id"] = tcid _forward(payload) except Exception as exc: - logger.debug("nemo-flow-bridge: on_pre_tool_call failed: %s", exc) + logger.debug("nemo-flow: on_pre_tool_call failed: %s", exc) def on_post_tool_call(**kwargs: Any) -> None: @@ -434,7 +434,7 @@ def on_post_tool_call(**kwargs: Any) -> None: payload["tool_call_id"] = tcid _forward(payload) except Exception as exc: - logger.debug("nemo-flow-bridge: on_post_tool_call failed: %s", exc) + logger.debug("nemo-flow: on_post_tool_call failed: %s", exc) # --------------------------------------------------------------------------- diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml similarity index 95% rename from examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml index c65fa9ac..4479628b 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugin-nemo-flow/plugin.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -name: nemo-flow-bridge +name: nemo-flow version: "0.0.1" description: "Forwards Hermes pre/post_api_request and pre/post_tool_call hooks to NeMo-Flow with exact bodies and stable tool_call_ids for paired Phoenix spans." author: "NVIDIA Corporation" diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py similarity index 100% rename from examples/personal-community-sentiment-triage/agents/hermes/plugin/__init__.py rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugin/plugin.yaml b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/plugin.yaml similarity index 100% rename from examples/personal-community-sentiment-triage/agents/hermes/plugin/plugin.yaml rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/plugin.yaml diff --git a/examples/personal-community-sentiment-triage/docs/collective-wisdom.md b/examples/personal-community-sentiment-triage/docs/collective-wisdom.md index f7a67a5a..8de6a78d 100644 --- a/examples/personal-community-sentiment-triage/docs/collective-wisdom.md +++ b/examples/personal-community-sentiment-triage/docs/collective-wisdom.md @@ -26,8 +26,8 @@ status: published This demo proves three durability properties of the agent in a single 15-minute walkthrough: -1. **Skills are learnable from conversation.** User A iteratively narrows a vague request into a specific output format, then expresses they'd like the same format next time and for coworkers — without ever using the words "skill," "save," or "write a file." The agent infers a reusable skill is the right durable mechanism, writes a `SKILL.md` under `/sandbox/.hermes-data/skills/`, and registers it via `nemoclaw_reload_skills` — see the `_reload_skills()` function in [`agents/hermes/plugin/__init__.py`](../agents/hermes/plugin/__init__.py). -2. **Skills survive a full sandbox rebuild.** `scripts/snapshot.sh` captures `/sandbox/.hermes-data/skills/` (see the state-dir list comment block at the top of that script). After `tear-down.sh` + `bring-up.sh`, `scripts/restore.sh` re-extracts the tarball, and the next session's `on_session_start` hook in [`plugin/__init__.py`](../agents/hermes/plugin/__init__.py) auto-rescans — no manual reload needed. +1. **Skills are learnable from conversation.** User A iteratively narrows a vague request into a specific output format, then expresses they'd like the same format next time and for coworkers — without ever using the words "skill," "save," or "write a file." The agent infers a reusable skill is the right durable mechanism, writes a `SKILL.md` under `/sandbox/.hermes-data/skills/`, and registers it via `nemoclaw_reload_skills` — see the `_reload_skills()` function in [`agents/hermes/plugins/nemoclaw/__init__.py`](../agents/hermes/plugins/nemoclaw/__init__.py). +2. **Skills survive a full sandbox rebuild.** `scripts/snapshot.sh` captures `/sandbox/.hermes-data/skills/` (see the state-dir list comment block at the top of that script). After `tear-down.sh` + `bring-up.sh`, `scripts/restore.sh` re-extracts the tarball, and the next session's `on_session_start` hook in [`plugins/nemoclaw/__init__.py`](../agents/hermes/plugins/nemoclaw/__init__.py) auto-rescans — no manual reload needed. 3. **Skills are not user-bound or channel-bound.** A different person, on a different channel, who never saw the original conversation, invokes the same skill and gets a structurally identical reply — because the skill (the file on disk), not the conversation, encodes the format. The README's [§ Persistence: collective wisdom across restarts](../README.md#persistence-collective-wisdom-across-restarts) covers the snapshot/restore mechanics in prose. This guide turns those mechanics into a reproducible end-to-end demo. From 72267c5ef6cab9ab14ff4d0ffbc45fcb462e5e67 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Sat, 23 May 2026 01:02:47 +0000 Subject: [PATCH 05/10] Revert to PHOENIX_ENDPOINT to be full OLTP HTTP/binary URL Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index c19f4f96..228b4274 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -210,7 +210,11 @@ RUN mkdir -p /etc/nemo-flow \ && if [ -z "$PHOENIX_URL" ]; then \ PHOENIX_ENABLED=false; PHOENIX_ENDPOINT=""; \ else \ - PHOENIX_ENABLED=true; PHOENIX_ENDPOINT="${PHOENIX_URL%/}/v1/traces"; \ + # PHOENIX_COLLECTOR_ENDPOINT is consumed verbatim — the user supplies + # the full OTLP HTTP/binary URL (typically including the /v1/traces + # suffix). The trailing-slash strip is the only normalization. See + # .env.example and README for the documented value format. + PHOENIX_ENABLED=true; PHOENIX_ENDPOINT="${PHOENIX_URL%/}"; \ fi \ && sed -e "s|@@PHOENIX_ENABLED@@|${PHOENIX_ENABLED}|g" \ -e "s|@@PHOENIX_ENDPOINT@@|${PHOENIX_ENDPOINT}|g" \ From 3a2b163d3bdc1c0fbaa92b7098be7952c418003b Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Sat, 23 May 2026 06:15:19 +0000 Subject: [PATCH 06/10] Fixes to allow use of Hermes TUI with tracing support Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 37 +++++++++++++++- .../agents/hermes/start.sh | 43 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index 228b4274..da91f290 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -123,7 +123,18 @@ RUN pip3 install --no-cache-dir --break-system-packages "httpx>=0.27" "markdown- # incrementally reconcile dependencies against v0.14.0's uv.lock. ARG HERMES_UPGRADE_VERSION=v2026.5.16 ARG HERMES_UPGRADE_TARBALL_SHA256=c0a554050a50ee9a62f3fa5cd288a167ba5640c42d647d100cdea084b7294143 -ARG HERMES_UV_EXTRAS="messaging web" +# messaging: slack-bolt + slack-sdk + aiohttp (and friends) for the Slack +# platform path. +# web: fastapi + uvicorn for Hermes's web tools. +# cli: simple-term-menu, used by `hermes chat` TUI menus. Without this the +# TUI banner path can try to lazy-install at runtime, which the +# locked-down sandbox can't satisfy. +# edge-tts: edge-tts==7.2.7. The TTS check_fn fires during banner +# enumeration regardless of toolset config; pre-installing avoids the +# PyPI fetch attempt at runtime. HERMES_DISABLE_LAZY_INSTALLS=1 (set +# below + in start.sh) is the belt-and-suspenders fallback for any +# other lazy feature we haven't pre-installed. +ARG HERMES_UV_EXTRAS="messaging web cli edge-tts" # Match the uv version NemoClaw uses for the base-image install. ARG UV_INSTALL_VERSION=0.11.8 @@ -204,7 +215,11 @@ RUN pip3 install --no-cache-dir --break-system-packages \ # plugins.toml — observability component (ATIF + OpenInference). COPY agents/hermes/nemo-flow/plugins.toml.in /tmp/nemo-flow-plugins.toml.in RUN mkdir -p /etc/nemo-flow \ - && printf '%s\n' '[agents.hermes]' 'command = "hermes"' > /etc/nemo-flow/config.toml \ + && printf '%s\n' \ + '[agents.hermes]' \ + 'command = "hermes"' \ + 'hooks_path = "/sandbox/.hermes/config.yaml"' \ + > /etc/nemo-flow/config.toml \ && chmod 444 /etc/nemo-flow/config.toml \ && PHOENIX_URL="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ && if [ -z "$PHOENIX_URL" ]; then \ @@ -227,7 +242,16 @@ RUN mkdir -p /etc/nemo-flow \ # (decode-proxy → OpenShell L7 proxy) handles credential placeholder # rewriting and hostname-based policy enforcement. The Python preload # rewrites Slack SDK-shaped placeholders before HTTPS serialization. +# +# Only HERMES_HOME and HERMES_DISABLE_LAZY_INSTALLS reliably reach +# user processes — OpenShell's exec-session allowlist strips most other +# HERMES_* vars. PID-1 (start.sh-launched gateway) keeps everything; +# HERMES_DISABLE_LAZY_INSTALLS is also re-exported in start.sh's bashrc +# snippet so interactive shells get it. See start.sh:install_configure_guard +# for the full rationale. ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 \ + HERMES_DISABLE_LAZY_INSTALLS=1 \ + HERMES_HOME=/sandbox/.hermes-data \ GH_TOKEN=openshell:resolve:env:GITHUB_TOKEN # Copy NemoClaw plugin for Hermes (Python-based) @@ -351,6 +375,15 @@ RUN chown root:root /sandbox/.hermes \ && chmod 444 /sandbox/.hermes/config.yaml \ && chmod 444 /sandbox/.hermes/.env +# The `hermes()` shell-function wrapper that routes interactive +# `hermes` / `hermes chat` invocations through `nemo-flow hermes --` +# (so the TUI's NEMO_FLOW_GATEWAY_URL is populated and traces flow) is +# installed at runtime by start.sh's install_configure_guard(). That +# function owns /sandbox/.bashrc's tracked configure-guard block via +# marker-bounded rewrite, and is the LAST writer to the file at sandbox +# init — so Dockerfile-time appends would be shadowed. The merged +# function (configure-guard + tracing wrapper) lives in start.sh. + # Pin config hash at build time for integrity verification at startup. RUN sha256sum /sandbox/.hermes/config.yaml /sandbox/.hermes/.env \ > /sandbox/.hermes/.config-hash \ diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 2cebabc5..e66c77e6 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -269,8 +269,38 @@ install_configure_guard() { local marker_begin="# nemoclaw-configure-guard begin" local marker_end="# nemoclaw-configure-guard end" local snippet + # Canonical hermes() wrapper for interactive sandbox shells. Appended + # LAST to /sandbox/.bashrc (rewrite_rc_marker_block appends after any + # Dockerfile-time content), so under bash's last-define-wins this + # shadows anything earlier. Cases: + # - setup/doctor: block in-sandbox config mutations + # - gateway: pass through (start.sh runs the long-running gateway; + # user-level `hermes gateway` is unusual but supported) + # - chat/no-args: route through `nemo-flow hermes --` so the TUI + # inherits NEMO_FLOW_GATEWAY_URL and traces flow to Phoenix/ATIF + # - everything else: pass through + # + # The two HERMES_* exports below MUST live here, not in the Dockerfile + # ENV: OpenShell's exec-session env allowlist strips non-HERMES_HOME + # HERMES_* vars, so anything baked into image ENV vanishes before the + # user's shell sees it. PID-1 (start.sh-launched gateway) does get the + # Docker ENV, hence we keep HERMES_DISABLE_LAZY_INSTALLS in both + # places. HERMES_TUI_THEME is only meaningful for the TUI, so bashrc + # alone is enough. + # - HERMES_TUI_THEME=dark: short-circuits Hermes's OSC 11 background + # probe (cli.py:_is_light_mode_detected priority 2 of 6) so the + # response doesn't leak into the TUI input buffer under `openshell + # sandbox connect`'s relayed PTY. COLORFGBG (priority 4) would + # also work but is stripped by the same allowlist. + # - HERMES_DISABLE_LAZY_INSTALLS=1: stops `hermes chat`'s banner + # from hanging on `uv pip install` attempts for any optional + # feature not pre-installed via HERMES_UV_EXTRAS (the sandbox + # can't reach PyPI by policy). Lazy-install check_fns return False + # and the tool is filtered out of the registry instead. read -r -d '' snippet <<'GUARD' || true # nemoclaw-configure-guard begin +export HERMES_TUI_THEME=dark +export HERMES_DISABLE_LAZY_INSTALLS=1 hermes() { case "$1" in setup|doctor) @@ -281,8 +311,19 @@ hermes() { echo " nemoclaw onboard --resume" >&2 return 1 ;; + gateway) + command hermes "$@" + ;; + chat|"") + # No exec — keep the bash shell alive so Ctrl+C / TUI exit returns + # the user to their sandbox prompt instead of dropping the whole + # `openshell sandbox connect` session. + /usr/local/bin/nemo-flow hermes -- "$@" + ;; + *) + command hermes "$@" + ;; esac - command hermes "$@" } # nemoclaw-configure-guard end GUARD From 56c822663bc04460ca124b422d764cee6e1351a8 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Tue, 26 May 2026 01:35:19 +0000 Subject: [PATCH 07/10] Clean up banner display for NemoClaw registration Signed-off-by: Matt Penn --- .../hermes/plugins/nemoclaw/__init__.py | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py index e270b90b..a2b4a26e 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py @@ -17,6 +17,7 @@ import json import os import subprocess +import sys import yaml @@ -90,6 +91,40 @@ def _get_sandbox_info(): } +def _build_banner(info): + # No Gateway field: at register() time Hermes's API server isn't up + # yet, so the live health check would always report "stopped". + lines = [ + "NemoClaw registered (Hermes)", + "", + f"Model: {info['model']}", + f"Provider: {info['provider']}", + "Tools: nemoclaw_status, nemoclaw_info,", + " nemoclaw_reload_skills", + ] + inner = max(len(line) for line in lines) + horizontal = "─" * (inner + 2) + + # Border in palette green, TTY-gated and NO_COLOR-respecting — matches + # NeMo Flow's launcher.rs:eprint_border_line. + use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR") + if use_color: + green, reset = "\x1b[38;5;112m", "\x1b[0m" + top = f"{green}╭{horizontal}╮{reset}" + bot = f"{green}╰{horizontal}╯{reset}" + pipe = f"{green}│{reset}" + else: + top = f"╭{horizontal}╮" + bot = f"╰{horizontal}╯" + pipe = "│" + + rows = ["", top] + for line in lines: + rows.append(f"{pipe} {line.ljust(inner)} {pipe}") + rows.append(bot) + return "\n".join(rows) + + def _handle_status(tool_input, **kwargs): """Handle the nemoclaw_status tool call. @@ -226,28 +261,16 @@ def register(ctx): description="Reload skills from disk without gateway restart", ) - # Startup banner on session start def _on_session_start(**kwargs): - # Refresh skill cache so skills installed since last session are - # immediately available as slash commands. _reload_skills() - info = _get_sandbox_info() - banner = ( - "\n" - " \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n" - " \u2502 NemoClaw registered (Hermes) \u2502\n" - " \u2502 \u2502\n" - f" \u2502 Model: {info['model']:<40}\u2502\n" - f" \u2502 Provider: {info['provider']:<40}\u2502\n" - f" \u2502 Gateway: {info['gateway']:<40}\u2502\n" - " \u2502 Tools: nemoclaw_status, nemoclaw_info, \u2502\n" - " \u2502 nemoclaw_reload_skills \u2502\n" - " \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n" - ) - try: - ctx.inject_message(banner, role="system") - except Exception: - print(banner) - ctx.register_hook("on_session_start", _on_session_start) + + # Print at register() time, not from on_session_start: on_session_start + # fires inside run_conversation and routes the banner into the first + # user-message frame. try/except so a config-read failure can't block + # tool registration above. + try: + print(_build_banner(_get_sandbox_info())) + except Exception: + pass From 27dfefcf39de4df858b853819c23976303acd90d Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Tue, 26 May 2026 02:55:58 +0000 Subject: [PATCH 08/10] Update Hermes integration to use NeMo-Relay Signed-off-by: Matt Penn --- .../.env.example | 2 +- .../README.md | 16 +-- .../agents/hermes/Dockerfile | 91 ++++++++------- .../agents/hermes/generate-config.ts | 30 ++--- .../agents/hermes/manifest.yaml | 2 +- .../{nemo-flow => nemo-relay}/finalize-shim | 16 +-- .../{nemo-flow => nemo-relay}/plugins.toml.in | 6 +- .../{nemo-flow => nemo-relay}/__init__.py | 36 +++--- .../{nemo-flow => nemo-relay}/plugin.yaml | 4 +- .../hermes/plugins/nemoclaw/__init__.py | 2 +- .../agents/hermes/start.sh | 108 ++++++++++++++---- .../policy.yaml | 2 +- .../scripts/03-sandbox.sh | 2 +- .../scripts/download-traces.sh | 4 +- 14 files changed, 193 insertions(+), 128 deletions(-) rename examples/personal-community-sentiment-triage/agents/hermes/{nemo-flow => nemo-relay}/finalize-shim (75%) rename examples/personal-community-sentiment-triage/agents/hermes/{nemo-flow => nemo-relay}/plugins.toml.in (73%) rename examples/personal-community-sentiment-triage/agents/hermes/plugins/{nemo-flow => nemo-relay}/__init__.py (93%) rename examples/personal-community-sentiment-triage/agents/hermes/plugins/{nemo-flow => nemo-relay}/plugin.yaml (75%) diff --git a/examples/personal-community-sentiment-triage/.env.example b/examples/personal-community-sentiment-triage/.env.example index 0b171da3..a1f41d7c 100644 --- a/examples/personal-community-sentiment-triage/.env.example +++ b/examples/personal-community-sentiment-triage/.env.example @@ -88,7 +88,7 @@ GITHUB_TOKEN= # DISCORD_BOT_TOKEN= # ── Optional: Phoenix OpenInference egress ─────────────────────────────── -# NeMo-Flow is installed unconditionally by the Dockerfile, so the agent +# NeMo-Relay is installed unconditionally by the Dockerfile, so the agent # always writes ATIF (Agent Trajectory Format) traces to /tmp/atif/ inside # the sandbox. Pull them off with `bash scripts/download-traces.sh` — no # collector or extra config required. diff --git a/examples/personal-community-sentiment-triage/README.md b/examples/personal-community-sentiment-triage/README.md index 1143bd9b..b55f6ffe 100644 --- a/examples/personal-community-sentiment-triage/README.md +++ b/examples/personal-community-sentiment-triage/README.md @@ -32,7 +32,7 @@ flowchart LR direction TB subgraph sandbox["OpenShell Sandbox"] - agent["Hermes Agent\nLLM + NemoFlow"] + agent["Hermes Agent\nLLM + NemoRelay"] outlookBridge["Outlook Bridge"] credSidecar["MS Graph Sidecar\n127.0.0.1:8766"] @@ -239,7 +239,7 @@ $ bash scripts/bring-up.sh The script auto-sources `.env`, then runs `01-gateway.sh` → `02-providers.sh` → `03-sandbox.sh` (select or register the local OpenShell gateway, upsert provider -credentials, build and launch the sandbox). The image always installs NeMo-Flow +credentials, build and launch the sandbox). The image always installs NeMo-Relay so the agent writes ATIF traces to `/tmp/atif/` regardless of Phoenix config. If `PHOENIX_COLLECTOR_ENDPOINT` is set, `03-sandbox.sh` additionally bakes the endpoint into the image so OpenInference traces stream into Phoenix at @@ -265,12 +265,12 @@ The example's Dockerfile drops the upstream `COPY nemoclaw-blueprint/` step — nothing in the Hermes runtime reads `/sandbox/.nemoclaw/blueprints/`, so this example is **fully self-contained** and never needs a NemoClaw checkout. -The Dockerfile always installs NeMo-Flow: an in-image `pip install` of the -`nemo-flow` version pinned by `NEMO_FLOW_VERSION` in +The Dockerfile always installs NeMo-Relay: an in-image `pip install` of the +`nemo-relay` version pinned by `NEMO_RELAY_VERSION` in [agents/hermes/Dockerfile](agents/hermes/Dockerfile) (from PyPI), plus a -re-install of Hermes with the NeMo-Flow integration patch fetched from -[NVIDIA/NeMo-Flow](https://github.com/NVIDIA/NeMo-Flow) at the pinned -`NEMO_FLOW_VERSION` tag and applied during the build (~1-2 min on a cold +re-install of Hermes with the NeMo-Relay integration patch fetched from +[NVIDIA/NeMo-Relay](https://github.com/NVIDIA/NeMo-Relay) at the pinned +`NEMO_RELAY_VERSION` tag and applied during the build (~1-2 min on a cold build, cached on rebuild). That alone is enough for the agent to write ATIF trace records to `/tmp/atif/` — capture them with [`scripts/download-traces.sh`](scripts/download-traces.sh). @@ -352,7 +352,7 @@ compatible-endpoint --model ` rather than `--provider` on sandbo | `NEMOCLAW_ENDPOINT_URL` | `https://integrate.api.nvidia.com/v1` | Upstream base URL for the `compatible-endpoint` provider. (`OPENAI_BASE_URL` is also accepted as a fallback.) | | `COMPATIBLE_API_KEY` | (none) | Inference API key. Mirrors NemoClaw's `REMOTE_PROVIDER_CONFIG.custom`. (`OPENAI_API_KEY` is also accepted.) | | `TOKEN_MANAGER_HOST` | `host.openshell.internal` | Host where the MS Graph token manager is reachable from inside the sandbox. | -| `PHOENIX_COLLECTOR_ENDPOINT` | (none) | Set to e.g. `http://host.openshell.internal:6006/v1/traces` to stream OpenInference traces to a Phoenix collector. ATIF trace generation does not depend on this — NeMo-Flow is always installed and writes ATIF locally to `/tmp/atif/` regardless. | +| `PHOENIX_COLLECTOR_ENDPOINT` | (none) | Set to e.g. `http://host.openshell.internal:6006/v1/traces` to stream OpenInference traces to a Phoenix collector. ATIF trace generation does not depend on this — NeMo-Relay is always installed and writes ATIF locally to `/tmp/atif/` regardless. | ## Verification (what success looks like) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index da91f290..ca6553c3 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -35,14 +35,16 @@ RUN pyinstaller \ --hidden-import aiohttp.web \ ms_graph_sidecar.py -# ── Stage 1: build the nemo-flow CLI binary ────────────────────────────────── +# ── Stage 1: build the nemo-relay CLI binary ────────────────────────────────── # Built inside ${BASE_IMAGE} so the resulting binary links against the same # glibc as the runtime — same constraint as the sidecar-builder stage above. -# 0.2.0 publishes nemo-flow-cli to crates.io, so `cargo install` is the -# simplest reproducible path. The ~3-5 min toolchain bootstrap is amortized -# across the cargo target cache when the version pin doesn't change. -FROM ${BASE_IMAGE} AS nemo-flow-builder -ARG NEMO_FLOW_CLI_VERSION=0.2.0 +# 0.3.0-beta.2 is the first NeMo-Relay release on crates.io (the predecessor +# crate `nemo-flow-cli` was published through 0.2.0 before the project's rename +# from NeMo-Flow). `cargo install` is the simplest reproducible path; the +# ~3-5 min toolchain bootstrap is amortized across the cargo target cache when +# the version pin doesn't change. +FROM ${BASE_IMAGE} AS nemo-relay-builder +ARG NEMO_RELAY_CLI_VERSION=0.3.0-beta.2 RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ build-essential ca-certificates curl pkg-config libssl-dev \ @@ -50,13 +52,13 @@ RUN apt-get update -qq \ && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain stable --profile minimal \ && /root/.cargo/bin/cargo install --locked --root /out \ - nemo-flow-cli --version "${NEMO_FLOW_CLI_VERSION}" -# Produces /out/bin/nemo-flow + nemo-relay-cli --version "${NEMO_RELAY_CLI_VERSION}" +# Produces /out/bin/nemo-relay # ── Main sandbox image ──────────────────────────────────────────────────────── FROM ${BASE_IMAGE} -# NeMo-Flow Phoenix OTLP endpoint — baked at image build time so start.sh +# NeMo-Relay Phoenix OTLP endpoint — baked at image build time so start.sh # reads it directly from the container ENV without relying on runtime injection. # Empty string means no telemetry (standard behavior). ARG PHOENIX_COLLECTOR_ENDPOINT="" @@ -113,7 +115,7 @@ RUN pip3 install --no-cache-dir --break-system-packages "httpx>=0.27" "markdown- # NemoClaw's base image (even at its latest tag) pins HERMES_VERSION=v2026.4.23 # (Hermes v0.11.0). v0.11.0's plugin hooks ship metadata-only kwargs to # pre_api_request / post_api_request — no real messages, no real response — -# so observability backends (Langfuse, NeMo-Flow's hermes adapter) cannot +# so observability backends (Langfuse, NeMo-Relay's hermes adapter) cannot # emit LLM spans with full content. v0.14.0 (v2026.5.16, "The Foundation # Release") adds rich-kwargs hook delivery and ships the bundled Langfuse # plugin that depends on it. @@ -162,32 +164,35 @@ RUN set -eu \ ${extras_args} --no-dev \ && chown -R sandbox:sandbox /opt/hermes -# ── NeMo-Flow 0.2.0 wrapped-CLI integration ───────────────────────────── -# Drop-in observability via `nemo-flow hermes -- gateway run`. Hermes stays -# unpatched (from BASE_IMAGE); native hook events configured in -# ~/.hermes/config.yaml route through the wrapper's ephemeral gateway, which -# runs the ATIF writer and OpenInference OTLP exporter from the baked -# /etc/nemo-flow/plugins.toml. No Hermes patch, no nemo-flow PyPI package, -# no HERMES_NEMO_FLOW_* env vars. +# ── NeMo-Relay 0.3.0 sidecar gateway integration ───────────────────────── +# Drop-in observability via a long-running sidecar gateway started by +# start.sh (`nemo-relay --bind 127.0.0.1:4040 ...`). Every Hermes process +# (PID-1 gateway, Slack/Outlook-driven turns, interactive TUI) discovers it +# via NEMO_RELAY_GATEWAY_URL in env and forwards hook events to a single +# correlated session. Hermes stays unpatched (from BASE_IMAGE); native hook +# events configured in ~/.hermes/config.yaml POST to the sidecar's +# /hooks/hermes endpoint, which runs the ATIF writer and OpenInference OTLP +# exporter from the baked /etc/nemo-relay/plugins.toml. No Hermes patch, no +# nemo-relay PyPI package, no HERMES_NEMO_RELAY_* env vars. # # ATIF writes are local-disk-only — no collector required. To additionally # stream OpenInference traces to Phoenix, set PHOENIX_COLLECTOR_ENDPOINT; # empty disables OpenInference but keeps ATIF on. # -# agents/hermes/patches/ is example-owned (not in NeMo-Flow upstream). It +# agents/hermes/patches/ is example-owned (not in NeMo-Relay upstream). It # holds the PYTHONPATH-targeted sitecustomize.py bootstrap (Slack-SDK # placeholder rewrite) and the nemoclaw_patches.py chain-loaded bundle # (Slack catch-all slash command). -COPY --from=nemo-flow-builder /out/bin/nemo-flow /usr/local/bin/nemo-flow -RUN chmod 755 /usr/local/bin/nemo-flow +COPY --from=nemo-relay-builder /out/bin/nemo-relay /usr/local/bin/nemo-relay +RUN chmod 755 /usr/local/bin/nemo-relay # Per-turn finalize shim. Hermes only fires `on_session_finalize` from its -# idle-session expiry watcher (~5 min default), but NeMo-Flow's ATIF writer +# idle-session expiry watcher (~5 min default), but NeMo-Relay's ATIF writer # and root-span closer only act on `on_session_finalize` / `on_session_reset`. # This shim, registered as a second hook on `on_session_end`, rewrites the # event name and re-posts to the gateway so each turn closes its agent scope. -COPY agents/hermes/nemo-flow/finalize-shim /usr/local/bin/nemo-flow-finalize-shim -RUN chmod 755 /usr/local/bin/nemo-flow-finalize-shim +COPY agents/hermes/nemo-relay/finalize-shim /usr/local/bin/nemo-relay-finalize-shim +RUN chmod 755 /usr/local/bin/nemo-relay-finalize-shim COPY agents/hermes/patches/ /usr/local/lib/nemoclaw-patches/ @@ -203,24 +208,24 @@ RUN pip3 install --no-cache-dir --break-system-packages \ && export PY_SITE_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" \ && ln -sfn /usr/local/lib/nemoclaw-patches/sitecustomize.py "${PY_SITE_DIR}/sitecustomize.py" -# ── NeMo-Flow plugin config (immutable, baked at build time) ──────────── -# Discovery order: /etc/nemo-flow → project ./.nemo-flow → user XDG -# (NeMo-Flow crates/cli/src/config.rs:618-633). /etc/ is the cleanest +# ── NeMo-Relay plugin config (immutable, baked at build time) ──────────── +# Discovery order: /etc/nemo-relay → project ./.nemo-relay → user XDG +# (NeMo-Relay crates/cli/src/config.rs:618-633). /etc/ is the cleanest # location for a containerized agent because it ignores CWD. # # Two files are baked here: -# config.toml — `[agents.hermes]` block so `nemo-flow hermes` finds a +# config.toml — `[agents.hermes]` block so `nemo-relay hermes` finds a # configured agent and skips the interactive setup wizard # (which fails in non-TTY sandboxes). # plugins.toml — observability component (ATIF + OpenInference). -COPY agents/hermes/nemo-flow/plugins.toml.in /tmp/nemo-flow-plugins.toml.in -RUN mkdir -p /etc/nemo-flow \ +COPY agents/hermes/nemo-relay/plugins.toml.in /tmp/nemo-relay-plugins.toml.in +RUN mkdir -p /etc/nemo-relay \ && printf '%s\n' \ '[agents.hermes]' \ 'command = "hermes"' \ 'hooks_path = "/sandbox/.hermes/config.yaml"' \ - > /etc/nemo-flow/config.toml \ - && chmod 444 /etc/nemo-flow/config.toml \ + > /etc/nemo-relay/config.toml \ + && chmod 444 /etc/nemo-relay/config.toml \ && PHOENIX_URL="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ && if [ -z "$PHOENIX_URL" ]; then \ PHOENIX_ENABLED=false; PHOENIX_ENDPOINT=""; \ @@ -233,9 +238,9 @@ RUN mkdir -p /etc/nemo-flow \ fi \ && sed -e "s|@@PHOENIX_ENABLED@@|${PHOENIX_ENABLED}|g" \ -e "s|@@PHOENIX_ENDPOINT@@|${PHOENIX_ENDPOINT}|g" \ - /tmp/nemo-flow-plugins.toml.in > /etc/nemo-flow/plugins.toml \ - && rm /tmp/nemo-flow-plugins.toml.in \ - && chmod 444 /etc/nemo-flow/plugins.toml + /tmp/nemo-relay-plugins.toml.in > /etc/nemo-relay/plugins.toml \ + && rm /tmp/nemo-relay-plugins.toml.in \ + && chmod 444 /etc/nemo-relay/plugins.toml # Hermes v2026.4.13+ auto-detects HTTPS_PROXY and skips fallback-IP # transport when a proxy is present. The sandbox proxy chain @@ -257,11 +262,11 @@ ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 \ # Copy NemoClaw plugin for Hermes (Python-based) COPY agents/hermes/plugins/nemoclaw/ /opt/nemoclaw-hermes-plugin/ -# Copy nemo-flow plugin: in-process forwarder for pre/post_api_request -# events that enriches NeMo-Flow hook payloads with the real OpenAI request +# Copy nemo-relay plugin: in-process forwarder for pre/post_api_request +# events that enriches NeMo-Relay hook payloads with the real OpenAI request # body and response body (the shell-hook path is metadata-only by design). # Requires Hermes >= v0.14.0 because earlier versions sanitized plugin kwargs. -COPY agents/hermes/plugins/nemo-flow/ /opt/nemo-flow-hermes-plugin/ +COPY agents/hermes/plugins/nemo-relay/ /opt/nemo-relay-hermes-plugin/ # Copy bridges and default cron jobs. # Install under /usr/local/lib/ so Landlock's read_only /usr rule permits access. @@ -286,7 +291,7 @@ RUN chmod 755 /usr/local/lib/nemoclaw-slack-shims/decode-proxy.py \ # Ensure sandbox user can read all /opt/nemoclaw-* and bridge files. # Source files may have restrictive permissions that Docker COPY preserves. RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ \ - && chmod -R a+rX /opt/nemo-flow-hermes-plugin/ \ + && chmod -R a+rX /opt/nemo-relay-hermes-plugin/ \ && chmod a+r /opt/nemoclaw-generate-config.ts \ && chmod -R a+rX /usr/local/lib/nemoclaw-bridges/ @@ -356,10 +361,10 @@ RUN node --experimental-strip-types /opt/nemoclaw-generate-config.ts RUN mkdir -p /sandbox/.hermes-data/plugins/nemoclaw \ && cp -r /opt/nemoclaw-hermes-plugin/* /sandbox/.hermes-data/plugins/nemoclaw/ -# Install nemo-flow plugin into Hermes. HERMES_HOME=/sandbox/.hermes-data +# Install nemo-relay plugin into Hermes. HERMES_HOME=/sandbox/.hermes-data # (set in start.sh) and Hermes' plugin loader scans $HERMES_HOME/plugins/. -RUN mkdir -p /sandbox/.hermes-data/plugins/nemo-flow \ - && cp -r /opt/nemo-flow-hermes-plugin/* /sandbox/.hermes-data/plugins/nemo-flow/ +RUN mkdir -p /sandbox/.hermes-data/plugins/nemo-relay \ + && cp -r /opt/nemo-relay-hermes-plugin/* /sandbox/.hermes-data/plugins/nemo-relay/ # Symlink SOUL.md into the immutable home so Hermes's ensure_hermes_home() finds it. RUN ln -s /sandbox/.hermes-data/SOUL.md /sandbox/.hermes/SOUL.md @@ -376,8 +381,8 @@ RUN chown root:root /sandbox/.hermes \ && chmod 444 /sandbox/.hermes/.env # The `hermes()` shell-function wrapper that routes interactive -# `hermes` / `hermes chat` invocations through `nemo-flow hermes --` -# (so the TUI's NEMO_FLOW_GATEWAY_URL is populated and traces flow) is +# `hermes` / `hermes chat` invocations through `nemo-relay hermes --` +# (so the TUI's NEMO_RELAY_GATEWAY_URL is populated and traces flow) is # installed at runtime by start.sh's install_configure_guard(). That # function owns /sandbox/.bashrc's tracked configure-guard block via # marker-bounded rewrite, and is the LAST writer to the file at sandbox diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index bd02ae8c..ae04f5f6 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -113,39 +113,39 @@ function main(): void { mode: "smart", timeout: 60, }, - // NeMo-Flow shell hooks — each event spawns `nemo-flow hook-forward hermes`, - // which reads the JSON payload from stdin and POSTs it to NEMO_FLOW_GATEWAY_URL - // (injected by the `nemo-flow hermes` wrapper). Events are the intersection of - // NeMo-Flow's HERMES_HOOK_EVENTS (installer.rs) and Hermes's VALID_HOOKS - // (hermes_cli/plugins.py). NeMo-Flow's "api_request_error" and "subagent_start" + // NeMo-Relay shell hooks — each event spawns `nemo-relay hook-forward hermes`, + // which reads the JSON payload from stdin and POSTs it to NEMO_RELAY_GATEWAY_URL + // (injected by the `nemo-relay hermes` wrapper). Events are the intersection of + // NeMo-Relay's HERMES_HOOK_EVENTS (installer.rs) and Hermes's VALID_HOOKS + // (hermes_cli/plugins.py). NeMo-Relay's "api_request_error" and "subagent_start" // are forward-looking — current Hermes only exposes "subagent_stop" and reports // request errors via "post_api_request" payloads, so we omit them here to // avoid "unknown hook event" warnings. // // pre_api_request / post_api_request and pre_tool_call / post_tool_call - // are NOT shell-forwarded. The in-process nemo-flow plugin - // (plugins/nemo-flow/) owns those events under Hermes v0.14.0: it + // are NOT shell-forwarded. The in-process nemo-relay plugin + // (plugins/nemo-relay/) owns those events under Hermes v0.14.0: it // receives the real `request_messages` list and the real `response` SDK // object as kwargs for api_request, and synthesizes stable tool_call_ids - // for tool_call events to work around NeMo-Flow's adapters/mod.rs:231-247 + // for tool_call events to work around NeMo-Relay's adapters/mod.rs:231-247 // synthesizing a fresh UUID per call when Hermes' defensive // `tool_call_id or ""` strips the id. The plugin forwards everything to - // NEMO_FLOW_GATEWAY_URL/hooks/hermes with payload.request.body / + // NEMO_RELAY_GATEWAY_URL/hooks/hermes with payload.request.body / // payload.response.raw_response / paired tool_call_id populated. The // adapter then marks provider_payload_exact=true (api_request) and pairs // pre/post tool events into a single Phoenix span. Shell-forwarding the // same events alongside the plugin would create duplicate lossy-summary // scopes on the gateway. // - // `on_session_end` gets a SECOND command (`nemo-flow-finalize-shim`) that + // `on_session_end` gets a SECOND command (`nemo-relay-finalize-shim`) that // synthesizes a per-turn `on_session_finalize`. Hermes fires real finalize // only from its idle-session expiry watcher (~5 min default), but - // NeMo-Flow's ATIF writer and root-span closer only act on finalize. The + // NeMo-Relay's ATIF writer and root-span closer only act on finalize. The // shim closes the agent scope every turn so each conversation produces a // complete Phoenix root span and a fresh ATIF JSON file. hooks: (() => { - const fwd = { command: "/usr/local/bin/nemo-flow hook-forward hermes", timeout: 30 }; - const finalize_shim = { command: "/usr/local/bin/nemo-flow-finalize-shim", timeout: 30 }; + const fwd = { command: "/usr/local/bin/nemo-relay hook-forward hermes", timeout: 30 }; + const finalize_shim = { command: "/usr/local/bin/nemo-relay-finalize-shim", timeout: 30 }; const events = [ "on_session_start", "on_session_finalize", "on_session_reset", "pre_llm_call", "post_llm_call", @@ -161,12 +161,12 @@ function main(): void { // root-owned + chmod 444 at build time. hooks_auto_accept: true, // Enable in-process Hermes plugins. nemoclaw provides sandbox status - // tools and the startup banner; nemo-flow owns the pre/post_api_request + // tools and the startup banner; nemo-relay owns the pre/post_api_request // events (see hooks comment above). Belt-and-suspenders against // config-migration changes — v0.14.0 also auto-discovers plugins under // $HERMES_HOME/plugins/, but explicit enablement survives schema bumps. plugins: { - enabled: ["nemoclaw", "nemo-flow"], + enabled: ["nemoclaw", "nemo-relay"], }, }; diff --git a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml index 09fef261..b8e30fe6 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml @@ -17,7 +17,7 @@ install_method: curl # curl install.sh | bash binary_path: /usr/local/bin/hermes version_command: "hermes --version" expected_version: "2026.4.8" -gateway_command: "hermes gateway run" # entrypoint wraps this with `nemo-flow hermes --` (see start.sh) +gateway_command: "hermes gateway run" # entrypoint wraps this with `nemo-relay hermes --` (see start.sh) # ── Health probe ──────────────────────────────────────────────── # The API server adapter listens on 8642 by default and exposes diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/finalize-shim b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim similarity index 75% rename from examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/finalize-shim rename to examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim index d42c8336..1a38d87c 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/finalize-shim +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim @@ -2,20 +2,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NeMo-Flow gateway-mode finalize shim. +# NeMo-Relay gateway-mode finalize shim. # # Hermes fires `on_session_end` at every `run_conversation` turn boundary but # only fires `on_session_finalize` from its idle-session expiry watcher every -# ~5 minutes. NeMo-Flow 0.2.0's gateway adapter (crates/cli/src/adapters/ -# hermes.rs:55-64) maps `on_session_end` to `TurnEnded`, which is documented +# ~5 minutes. NeMo-Relay 0.3.0's gateway adapter (crates/cli/src/adapters/ +# hermes.rs:52-64) maps `on_session_end` to `TurnEnded`, which is documented # as NOT closing the agent scope — and the ATIF writer in -# crates/core/src/observability/plugin_component.rs:684-685 only writes when -# the agent scope closes. Result: orphaned Phoenix spans and no ATIF files -# per turn. +# crates/core/src/observability/plugin_component.rs (write_atif_file callers) +# only writes when the agent scope closes. Result: orphaned Phoenix spans and +# no ATIF files per turn. # # This shim is registered as a SECOND command on `on_session_end`. It rewrites # the JSON payload's `hook_event_name` to `on_session_finalize` and pipes it -# back into `nemo-flow hook-forward hermes`, so the gateway closes the agent +# back into `nemo-relay hook-forward hermes`, so the gateway closes the agent # scope and writes ATIF every turn. The first command (the unmodified # `hook-forward`) still delivers the on_session_end event for any other # downstream handling. @@ -38,7 +38,7 @@ def main() -> int: rewritten = json.dumps(payload) proc = subprocess.run( - ["/usr/local/bin/nemo-flow", "hook-forward", "hermes"], + ["/usr/local/bin/nemo-relay", "hook-forward", "hermes"], input=rewritten, text=True, env=os.environ.copy(), diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/plugins.toml.in b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/plugins.toml.in similarity index 73% rename from examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/plugins.toml.in rename to examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/plugins.toml.in index bb658008..1bcc2591 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/nemo-flow/plugins.toml.in +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/plugins.toml.in @@ -1,10 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NeMo-Flow gateway plugin config — installed at /etc/nemo-flow/plugins.toml. +# NeMo-Relay gateway plugin config — installed at /etc/nemo-relay/plugins.toml. # Substituted at image build time from PHOENIX_COLLECTOR_ENDPOINT. -# Schema: nemo_flow.observability.ObservabilityConfig -# (NeMo-Flow python/nemo_flow/observability.py). +# Schema: nemo_relay.observability.ObservabilityConfig +# (NeMo-Relay python/nemo_relay/observability.py). version = 1 diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py similarity index 93% rename from examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py index e1625d85..761ff843 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """ -nemo-flow: in-process Hermes plugin that forwards pre/post_api_request -hooks to NeMo-Flow with the real request body and response body attached. +nemo-relay: in-process Hermes plugin that forwards pre/post_api_request +hooks to NeMo-Relay with the real request body and response body attached. Under Hermes v0.14.0, plugin hooks carry real data: - pre_api_request receives `request_messages` (list of dicts), `user_message`, @@ -12,16 +12,16 @@ OpenAI ChatCompletion shape with .choices/.usage/.model/.id) and `assistant_message` (a NormalizedResponse with .content/.tool_calls). -NeMo-Flow's Hermes adapter (crates/cli/src/adapters/hermes.rs) flips +NeMo-Relay's Hermes adapter (crates/cli/src/adapters/hermes.rs) flips `provider_payload_exact` to true and emits Phoenix LLM spans with the full prompt+completion when it finds `payload.request.body` (pre) or `payload.response.{raw_response,choices,assistant_message}` (post). This plugin builds those payload shapes from the in-process kwargs and POSTs them to -`${NEMO_FLOW_GATEWAY_URL}/hooks/hermes` — the URL is exported into the Hermes -child env by the `nemo-flow hermes -- gateway run` wrapper at start.sh. +`${NEMO_RELAY_GATEWAY_URL}/hooks/hermes` — the URL is exported into the Hermes +child env by the `nemo-relay hermes -- gateway run` wrapper at start.sh. Failure mode is fail-open: any exception is swallowed and logged at debug. -Hermes turns must never break because the bridge can't reach NeMo-Flow. +Hermes turns must never break because the bridge can't reach NeMo-Relay. Modeled on the bundled Langfuse plugin (`hermes-agent/plugins/observability/langfuse/__init__.py` in the Hermes @@ -79,13 +79,13 @@ def _gateway_url() -> Optional[str]: if _GATEWAY_LOOKED_UP: return _GATEWAY_URL _GATEWAY_LOOKED_UP = True - url = os.environ.get("NEMO_FLOW_GATEWAY_URL", "").strip() + url = os.environ.get("NEMO_RELAY_GATEWAY_URL", "").strip() if not url: if not _DISABLED_LOGGED: logger.debug( - "nemo-flow: NEMO_FLOW_GATEWAY_URL is not set; " + "nemo-relay: NEMO_RELAY_GATEWAY_URL is not set; " "bridge will not forward hooks (expected when Hermes " - "runs outside `nemo-flow hermes -- ...`)." + "runs outside `nemo-relay hermes -- ...`)." ) _DISABLED_LOGGED = True return None @@ -104,7 +104,7 @@ def _client(): _CLIENT = httpx.Client(timeout=2.0) return _CLIENT except Exception as exc: # pragma: no cover - logger.debug("nemo-flow: failed to construct httpx client: %s", exc) + logger.debug("nemo-relay: failed to construct httpx client: %s", exc) return None @@ -191,7 +191,7 @@ def _serialize_tool_calls(tool_calls: Any) -> list: def _serialize_assistant_message(obj: Any) -> Optional[dict]: - """Pull the fields NeMo-Flow's adapter inspects on + """Pull the fields NeMo-Relay's adapter inspects on `response.assistant_message` (adapters/hermes.rs:264-280).""" if obj is None: return None @@ -258,11 +258,11 @@ def _forward(payload: dict) -> None: try: client.post(f"{url}/hooks/hermes", json=_cap_payload(payload)) except Exception as exc: - logger.debug("nemo-flow: forward to %s failed: %s", url, exc) + logger.debug("nemo-relay: forward to %s failed: %s", url, exc) def _correlation(kwargs: dict) -> dict: - """The fields NeMo-Flow's adapter uses to synthesize api_call_id and + """The fields NeMo-Relay's adapter uses to synthesize api_call_id and correlate hook events back to the right session/turn scope.""" return { "task_id": kwargs.get("task_id"), @@ -318,7 +318,7 @@ def on_pre_api_request(**kwargs: Any) -> None: conversation_history=kwargs.get("conversation_history"), user_message=kwargs.get("user_message"), ) - # Wrap as {"messages": [...], "model": ..., ...} to match NeMo-Flow's + # Wrap as {"messages": [...], "model": ..., ...} to match NeMo-Relay's # documented LlmRequest.content convention # (docs/integrate-frameworks/wrap-llm-calls.md, asserted by # crates/core/tests/unit/observability/openinference_tests.rs). With @@ -338,7 +338,7 @@ def on_pre_api_request(**kwargs: Any) -> None: } _forward(payload) except Exception as exc: - logger.debug("nemo-flow: on_pre_api_request failed: %s", exc) + logger.debug("nemo-relay: on_pre_api_request failed: %s", exc) def on_post_api_request(**kwargs: Any) -> None: @@ -364,7 +364,7 @@ def on_post_api_request(**kwargs: Any) -> None: } _forward(payload) except Exception as exc: - logger.debug("nemo-flow: on_post_api_request failed: %s", exc) + logger.debug("nemo-relay: on_post_api_request failed: %s", exc) def on_pre_tool_call(**kwargs: Any) -> None: @@ -391,7 +391,7 @@ def on_pre_tool_call(**kwargs: Any) -> None: payload["tool_call_id"] = tcid _forward(payload) except Exception as exc: - logger.debug("nemo-flow: on_pre_tool_call failed: %s", exc) + logger.debug("nemo-relay: on_pre_tool_call failed: %s", exc) def on_post_tool_call(**kwargs: Any) -> None: @@ -434,7 +434,7 @@ def on_post_tool_call(**kwargs: Any) -> None: payload["tool_call_id"] = tcid _forward(payload) except Exception as exc: - logger.debug("nemo-flow: on_post_tool_call failed: %s", exc) + logger.debug("nemo-relay: on_post_tool_call failed: %s", exc) # --------------------------------------------------------------------------- diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/plugin.yaml similarity index 75% rename from examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml rename to examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/plugin.yaml index 4479628b..dfec8bda 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-flow/plugin.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/plugin.yaml @@ -1,9 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -name: nemo-flow +name: nemo-relay version: "0.0.1" -description: "Forwards Hermes pre/post_api_request and pre/post_tool_call hooks to NeMo-Flow with exact bodies and stable tool_call_ids for paired Phoenix spans." +description: "Forwards Hermes pre/post_api_request and pre/post_tool_call hooks to NeMo-Relay with exact bodies and stable tool_call_ids for paired Phoenix spans." author: "NVIDIA Corporation" manifest_version: 1 hooks: diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py index a2b4a26e..0c9fca20 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py @@ -106,7 +106,7 @@ def _build_banner(info): horizontal = "─" * (inner + 2) # Border in palette green, TTY-gated and NO_COLOR-respecting — matches - # NeMo Flow's launcher.rs:eprint_border_line. + # NeMo Relay's launcher.rs:eprint_border_line. use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR") if use_color: green, reset = "\x1b[38;5;112m", "\x1b[0m" diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index e66c77e6..5ff8efe1 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -112,6 +112,11 @@ PUBLIC_PORT=8642 # Hermes binds to 127.0.0.1 regardless of config (upstream bug). # Run it on an internal port and use socat to expose on PUBLIC_PORT. INTERNAL_PORT=18642 +# Persistent NeMo-Relay gateway port. Every Hermes process discovers it via +# NEMO_RELAY_GATEWAY_URL in env and forwards hook events to /hooks/hermes — +# replacing the per-invocation `nemo-relay hermes --` ephemeral gateway model. +# 4040 is the upstream default (NeMo-Relay crates/cli/src/config.rs:419). +NEMO_RELAY_GATEWAY_PORT=4040 # Hermes writes state files (PID, state.db, .channel_directory) directly into # HERMES_HOME. We cannot point it at the immutable /sandbox/.hermes dir. @@ -276,8 +281,8 @@ install_configure_guard() { # - setup/doctor: block in-sandbox config mutations # - gateway: pass through (start.sh runs the long-running gateway; # user-level `hermes gateway` is unusual but supported) - # - chat/no-args: route through `nemo-flow hermes --` so the TUI - # inherits NEMO_FLOW_GATEWAY_URL and traces flow to Phoenix/ATIF + # - chat/no-args: route through `nemo-relay hermes --` so the TUI + # inherits NEMO_RELAY_GATEWAY_URL and traces flow to Phoenix/ATIF # - everything else: pass through # # The two HERMES_* exports below MUST live here, not in the Dockerfile @@ -318,7 +323,7 @@ hermes() { # No exec — keep the bash shell alive so Ctrl+C / TUI exit returns # the user to their sandbox prompt instead of dropping the whole # `openshell sandbox connect` session. - /usr/local/bin/nemo-flow hermes -- "$@" + /usr/local/bin/nemo-relay hermes -- "$@" ;; *) command hermes "$@" @@ -374,7 +379,7 @@ start_gateway_log_stream() { } # Force the OpenInference BatchSpanProcessor (OpenTelemetry SDK 0.31, used -# by nemo-flow's HTTP exporter at +# by nemo-relay's HTTP exporter at # crates/core/src/observability/openinference.rs:449-460) to flush every # span immediately instead of batching for 5 seconds. Without these, real # multi-scope Hermes turns produce a single larger POST that the OpenShell @@ -382,7 +387,7 @@ start_gateway_log_stream() { # causing silent span loss. With BSP_MAX_EXPORT_BATCH_SIZE=1 every span # becomes a small single-span POST, matching the manual-probe shape that # we confirmed lands cleanly. Tracked upstream as missing force_flush() -# on turn boundary in nemo-flow's session.rs end_turn(). +# on turn boundary in nemo-relay's session.rs end_turn(). # # Exports into the parent shell so both privilege paths inherit the same # values without duplicating the rationale at each call site. @@ -444,6 +449,48 @@ start_decode_proxy() { echo "[gateway] decode-proxy failed to start — placeholder rewriting may not work" >&2 } +# ── NeMo-Relay sidecar gateway ────────────────────────────────── +# Long-running standalone NeMo-Relay gateway that all Hermes processes +# (PID-1 gateway, Slack/Outlook bridge-driven turns, interactive TUI in +# Phase 2) forward hook events to via NEMO_RELAY_GATEWAY_URL. Replaces the +# old per-invocation `nemo-relay hermes -- ` ephemeral-gateway model so +# all telemetry lands in a single Phoenix/ATIF correlated session. +NEMO_RELAY_PID="" +start_nemo_relay_sidecar() { + if ! [ -x /usr/local/bin/nemo-relay ]; then + echo "[nemo-relay] binary not found at /usr/local/bin/nemo-relay, skipping" >&2 + return 0 + fi + # OTEL BSP tunings shape the OpenInference exporter's flush behavior; on the + # sidecar process now, not on hermes — see _export_otel_bsp_tunings comment. + _export_otel_bsp_tunings + if [ "$(id -u)" -eq 0 ]; then + nohup gosu gateway /usr/local/bin/nemo-relay \ + --bind "127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ + --plugin-config /etc/nemo-relay/plugins.toml \ + >>/tmp/nemo-relay.log 2>&1 & + else + nohup /usr/local/bin/nemo-relay \ + --bind "127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ + --plugin-config /etc/nemo-relay/plugins.toml \ + >>/tmp/nemo-relay.log 2>&1 & + fi + NEMO_RELAY_PID=$! + # Wait for /healthz before returning so the PID-1 hermes launch downstream + # doesn't race the sidecar (else first-turn events drop silently). + local attempts=0 + while [ "$attempts" -lt 30 ]; do + if curl -sf "http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}/healthz" >/dev/null 2>&1; then + echo "[nemo-relay] sidecar healthy on 127.0.0.1:${NEMO_RELAY_GATEWAY_PORT} (pid $NEMO_RELAY_PID)" >&2 + return 0 + fi + sleep 0.5 + attempts=$((attempts + 1)) + done + echo "[nemo-relay] WARNING: sidecar did not become healthy within 15s (pid $NEMO_RELAY_PID) — telemetry may be missing" >&2 + return 0 +} + # Outlook bridge / MS Graph sidecar PIDs (populated at launch). OUTLOOK_BRIDGE_PID="" MS_GRAPH_SIDECAR_PID="" @@ -584,6 +631,7 @@ export GITHUB_TOKEN="${GITHUB_TOKEN:-openshell:resolve:env:GITHUB_TOKEN}" export OUTLOOK_CLIENT_ID="${OUTLOOK_CLIENT_ID:-openshell:resolve:env:OUTLOOK_CLIENT_ID}" export OUTLOOK_SESSION_UUID="${OUTLOOK_SESSION_UUID:-openshell:resolve:env:OUTLOOK_SESSION_UUID}" export MS_GRAPH_SIDECAR_URL="http://127.0.0.1:${SIDECAR_PORT}" +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" PROXYEOF for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO; do _ca_env_value="${!_ca_env_name:-}" @@ -624,17 +672,20 @@ if [ "$(id -u)" -ne 0 ]; then # Prepare ATIF telemetry directory (ephemeral, writable by the current user). mkdir -p /tmp/atif - # NeMo-Flow observability is configured via /etc/nemo-flow/plugins.toml + # NeMo-Relay observability is configured via /etc/nemo-relay/plugins.toml # (baked at image build time). Verify the binary and config are present. - if [ -x /usr/local/bin/nemo-flow ] \ - && [ -r /etc/nemo-flow/config.toml ] \ - && [ -r /etc/nemo-flow/plugins.toml ]; then - echo "[nemo-flow] gateway wrapper ready (config.toml + plugins.toml in /etc/nemo-flow)" | tee -a /tmp/gateway.log >&2 + if [ -x /usr/local/bin/nemo-relay ] \ + && [ -r /etc/nemo-relay/config.toml ] \ + && [ -r /etc/nemo-relay/plugins.toml ]; then + echo "[nemo-relay] binary + config present (config.toml + plugins.toml in /etc/nemo-relay)" | tee -a /tmp/gateway.log >&2 else - echo "[nemo-flow] WARNING: gateway wrapper or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 + echo "[nemo-relay] WARNING: binary or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi - _export_otel_bsp_tunings + # Start the NeMo-Relay sidecar BEFORE PID-1 hermes, so the gateway's first + # turn finds NEMO_RELAY_GATEWAY_URL reachable and doesn't drop hook events. + start_nemo_relay_sidecar + HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -642,7 +693,8 @@ if [ "$(id -u)" -ne 0 ]; then http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ - nohup /usr/local/bin/nemo-flow hermes -- gateway run >>/tmp/gateway.log 2>&1 & + NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ + nohup hermes gateway run >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched (pid $GATEWAY_PID)" >&2 start_gateway_log_stream @@ -651,6 +703,7 @@ if [ "$(id -u)" -ne 0 ]; then # registration and the final append is a small race window (same as before # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") + [ -n "${NEMO_RELAY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$NEMO_RELAY_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh SANDBOX_WAIT_PID="$GATEWAY_PID" @@ -687,12 +740,12 @@ prepare_restricted_log /tmp/gateway.log gateway:gateway 600 # gateway user (launched via gosu below) can write to it. mkdir -p /tmp/atif chown gateway:gateway /tmp/atif -# NeMo-Flow observability is configured via /etc/nemo-flow/plugins.toml +# NeMo-Relay observability is configured via /etc/nemo-relay/plugins.toml # (baked at image build time). Verify the binary and config are present. -if [ -x /usr/local/bin/nemo-flow ] && [ -r /etc/nemo-flow/plugins.toml ]; then - echo "[nemo-flow] gateway wrapper ready (plugins.toml=/etc/nemo-flow/plugins.toml)" | tee -a /tmp/gateway.log >&2 +if [ -x /usr/local/bin/nemo-relay ] && [ -r /etc/nemo-relay/plugins.toml ]; then + echo "[nemo-relay] binary + config present (plugins.toml=/etc/nemo-relay/plugins.toml)" | tee -a /tmp/gateway.log >&2 else - echo "[nemo-flow] WARNING: gateway wrapper missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 + echo "[nemo-relay] WARNING: binary or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi # Defence-in-depth: verify /tmp file permissions before launching services. @@ -705,12 +758,17 @@ validate_config_symlinks "${HERMES_IMMUTABLE}" "${HERMES_WRITABLE}" # Lock .hermes directory after validation. harden_config_symlinks "${HERMES_IMMUTABLE}" "hermes" -# Start the gateway as the 'gateway' user, wrapped in `nemo-flow hermes`. -# The wrapper binds an ephemeral 127.0.0.1 gateway, exports NEMO_FLOW_GATEWAY_URL -# into Hermes's environment, and spawns `hermes gateway run` as the child. -# Hermes's hook subprocesses inherit NEMO_FLOW_GATEWAY_URL and forward -# events to the in-proc gateway via `nemo-flow hook-forward hermes`. -_export_otel_bsp_tunings +# Start the NeMo-Relay sidecar (as 'gateway' user) BEFORE PID-1 hermes, so +# the gateway's first turn finds NEMO_RELAY_GATEWAY_URL reachable and +# doesn't drop hook events. start_nemo_relay_sidecar waits on /healthz. +start_nemo_relay_sidecar + +# Start Hermes as the 'gateway' user. NEMO_RELAY_GATEWAY_URL points at the +# sidecar started above; Hermes's nemo-relay plugin reads the env var at +# request time and POSTs hook events to /hooks/hermes on the sidecar. +# Slack/Outlook bridge-driven turns inherit this env var via the explicit +# launch list (the bridges themselves don't emit telemetry, but their +# messages funnel into this PID-1 process which does). HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ @@ -718,7 +776,8 @@ HERMES_HOME="${HERMES_WRITABLE}" \ http_proxy="${_PROXY_URL}" \ PYTHONPATH="${PATCHES_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ API_SERVER_KEY="nemoclaw-internal" \ - nohup gosu gateway /usr/local/bin/nemo-flow hermes -- gateway run \ + NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ + nohup gosu gateway hermes gateway run \ >>/tmp/gateway.log 2>&1 & GATEWAY_PID=$! echo "[gateway] hermes gateway launched as 'gateway' user (pid $GATEWAY_PID)" >&2 @@ -728,6 +787,7 @@ start_gateway_log_stream # registration and the final append is a small race window (same as before # the shared-library refactor). Acceptable for entrypoint-level cleanup. SANDBOX_CHILD_PIDS=("$GATEWAY_PID") +[ -n "${NEMO_RELAY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$NEMO_RELAY_PID") [ -n "${DECODE_PROXY_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$DECODE_PROXY_PID") [ -n "${GATEWAY_LOG_TAIL_PID:-}" ] && SANDBOX_CHILD_PIDS+=("$GATEWAY_LOG_TAIL_PID") # shellcheck disable=SC2034 # read by cleanup_on_signal from sandbox-init.sh diff --git a/examples/personal-community-sentiment-triage/policy.yaml b/examples/personal-community-sentiment-triage/policy.yaml index a29e5da8..ded9cca8 100644 --- a/examples/personal-community-sentiment-triage/policy.yaml +++ b/examples/personal-community-sentiment-triage/policy.yaml @@ -129,7 +129,7 @@ network_policies: method: POST path: /** binaries: - - path: /usr/local/bin/nemo-flow + - path: /usr/local/bin/nemo-relay - path: /usr/bin/python3 - path: /usr/bin/python3.13 - path: /opt/hermes/.venv/bin/python diff --git a/examples/personal-community-sentiment-triage/scripts/03-sandbox.sh b/examples/personal-community-sentiment-triage/scripts/03-sandbox.sh index 4b2c62d4..1f866e00 100755 --- a/examples/personal-community-sentiment-triage/scripts/03-sandbox.sh +++ b/examples/personal-community-sentiment-triage/scripts/03-sandbox.sh @@ -121,7 +121,7 @@ if [[ -n "${NEMOCLAW_MODEL:-}" ]]; then fi # Phoenix endpoint — bake into the image so the agent emits OpenInference -# traces to the collector. NeMo-Flow itself is now installed unconditionally +# traces to the collector. NeMo-Relay itself is now installed unconditionally # by the Dockerfile (ATIF traces always written to /tmp/atif/), so this # block is Phoenix-specific: no point overwriting the ARG with empty when # the user hasn't configured a collector. diff --git a/examples/personal-community-sentiment-triage/scripts/download-traces.sh b/examples/personal-community-sentiment-triage/scripts/download-traces.sh index 897b019a..ef90fd79 100755 --- a/examples/personal-community-sentiment-triage/scripts/download-traces.sh +++ b/examples/personal-community-sentiment-triage/scripts/download-traces.sh @@ -5,8 +5,8 @@ # Pull Agent Trajectory Format (ATIF) traces off the running sandbox into a # host-side tarball for offline analysis. # -# Where ATIF comes from: Hermes's NeMo-Flow integration writes per-turn -# trajectory records to HERMES_NEMO_FLOW_ATIF_DIR=/tmp/atif inside the +# Where ATIF comes from: Hermes's NeMo-Relay integration writes per-turn +# trajectory records to HERMES_NEMO_RELAY_ATIF_DIR=/tmp/atif inside the # sandbox (see agents/hermes/start.sh). The directory is ephemeral — it # lives on the sandbox's writable layer and disappears with the container # on tear-down — so capture before destroying the sandbox if you want to From c01839911fdb86f1dbc433d93fe99cedf4cc4b1b Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Tue, 26 May 2026 03:34:31 +0000 Subject: [PATCH 09/10] Refactor Hermes Dockerfile and start.sh for improved NeMo-Relay integration - Simplified comments and improved clarity regarding the NeMo-Relay sidecar gateway and its interaction with Hermes processes. - Introduced a new `hermes-cli-shim` script to manage interactive shell invocations of the `hermes` CLI, ensuring proper telemetry flow and configuration handling. - Removed obsolete functions from `start.sh` related to rc-file management, streamlining the script for better maintainability. Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 42 ++-- .../agents/hermes/nemo-relay/hermes-cli-shim | 26 +++ .../agents/hermes/start.sh | 188 ++---------------- 3 files changed, 61 insertions(+), 195 deletions(-) create mode 100644 examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/hermes-cli-shim diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index ca6553c3..9a1049af 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -165,19 +165,11 @@ RUN set -eu \ && chown -R sandbox:sandbox /opt/hermes # ── NeMo-Relay 0.3.0 sidecar gateway integration ───────────────────────── -# Drop-in observability via a long-running sidecar gateway started by -# start.sh (`nemo-relay --bind 127.0.0.1:4040 ...`). Every Hermes process -# (PID-1 gateway, Slack/Outlook-driven turns, interactive TUI) discovers it -# via NEMO_RELAY_GATEWAY_URL in env and forwards hook events to a single -# correlated session. Hermes stays unpatched (from BASE_IMAGE); native hook -# events configured in ~/.hermes/config.yaml POST to the sidecar's -# /hooks/hermes endpoint, which runs the ATIF writer and OpenInference OTLP -# exporter from the baked /etc/nemo-relay/plugins.toml. No Hermes patch, no -# nemo-relay PyPI package, no HERMES_NEMO_RELAY_* env vars. -# -# ATIF writes are local-disk-only — no collector required. To additionally -# stream OpenInference traces to Phoenix, set PHOENIX_COLLECTOR_ENDPOINT; -# empty disables OpenInference but keeps ATIF on. +# Hermes stays unpatched (from BASE_IMAGE). start.sh launches a long-running +# nemo-relay daemon; Hermes processes POST hook events to it via +# NEMO_RELAY_GATEWAY_URL. ATIF writes are local-disk-only (no collector +# required); set PHOENIX_COLLECTOR_ENDPOINT to additionally export +# OpenInference traces to Phoenix. # # agents/hermes/patches/ is example-owned (not in NeMo-Relay upstream). It # holds the PYTHONPATH-targeted sitecustomize.py bootstrap (Slack-SDK @@ -194,6 +186,13 @@ RUN chmod 755 /usr/local/bin/nemo-relay COPY agents/hermes/nemo-relay/finalize-shim /usr/local/bin/nemo-relay-finalize-shim RUN chmod 755 /usr/local/bin/nemo-relay-finalize-shim +# Interactive-shell shim for `hermes`. Prepended to PATH for sandbox shells +# (via _PROXY_ENV_FILE in start.sh) so `hermes` resolves here first; blocks +# setup/doctor in-sandbox and execs the upstream binary for everything else. +RUN mkdir -p /usr/local/lib/nemoclaw/bin +COPY agents/hermes/nemo-relay/hermes-cli-shim /usr/local/lib/nemoclaw/bin/hermes +RUN chmod 755 /usr/local/lib/nemoclaw/bin/hermes + COPY agents/hermes/patches/ /usr/local/lib/nemoclaw-patches/ # Pin slack-bolt / python-telegram-bot / pyyaml / httpx into Hermes's venv @@ -251,9 +250,8 @@ RUN mkdir -p /etc/nemo-relay \ # Only HERMES_HOME and HERMES_DISABLE_LAZY_INSTALLS reliably reach # user processes — OpenShell's exec-session allowlist strips most other # HERMES_* vars. PID-1 (start.sh-launched gateway) keeps everything; -# HERMES_DISABLE_LAZY_INSTALLS is also re-exported in start.sh's bashrc -# snippet so interactive shells get it. See start.sh:install_configure_guard -# for the full rationale. +# HERMES_DISABLE_LAZY_INSTALLS is also re-exported from start.sh's +# _PROXY_ENV_FILE so interactive shells get it. ENV HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=1 \ HERMES_DISABLE_LAZY_INSTALLS=1 \ HERMES_HOME=/sandbox/.hermes-data \ @@ -380,14 +378,10 @@ RUN chown root:root /sandbox/.hermes \ && chmod 444 /sandbox/.hermes/config.yaml \ && chmod 444 /sandbox/.hermes/.env -# The `hermes()` shell-function wrapper that routes interactive -# `hermes` / `hermes chat` invocations through `nemo-relay hermes --` -# (so the TUI's NEMO_RELAY_GATEWAY_URL is populated and traces flow) is -# installed at runtime by start.sh's install_configure_guard(). That -# function owns /sandbox/.bashrc's tracked configure-guard block via -# marker-bounded rewrite, and is the LAST writer to the file at sandbox -# init — so Dockerfile-time appends would be shadowed. The merged -# function (configure-guard + tracing wrapper) lives in start.sh. +# Interactive `hermes` invocations resolve through a PATH-prepended shim at +# /usr/local/lib/nemoclaw/bin/hermes (installed above). The shim blocks +# setup/doctor and execs the upstream binary; NEMO_RELAY_GATEWAY_URL in env +# carries telemetry to the sidecar. # Pin config hash at build time for integrity verification at startup. RUN sha256sum /sandbox/.hermes/config.yaml /sandbox/.hermes/.env \ diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/hermes-cli-shim b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/hermes-cli-shim new file mode 100644 index 00000000..0e2d1383 --- /dev/null +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/hermes-cli-shim @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# PATH-prepended shim for the `hermes` CLI in interactive sandbox shells. +# Installed at /usr/local/lib/nemoclaw/bin/hermes and reached by PATH order +# (set in /tmp/nemoclaw-proxy-env.sh, sourced by /sandbox/.bashrc). +# +# For setup/doctor it prints a brief note about the in-sandbox config +# lifecycle (writes go to /sandbox/.hermes-data and are reset on next +# bring-up unless captured via scripts/snapshot.sh + scripts/restore.sh), +# then execs the upstream binary. All other subcommands are pure passthrough. +# Telemetry flows via NEMO_RELAY_GATEWAY_URL in env. + +set -euo pipefail + +case "${1:-}" in + setup|doctor) + echo "Note: 'hermes $1' writes to /sandbox/.hermes-data/ — preserve across" >&2 + echo "restarts with 'scripts/snapshot.sh' + 'scripts/restore.sh', else changes" >&2 + echo "reset to the host-managed config on next sandbox bring-up." >&2 + echo "" >&2 + ;; +esac + +exec /usr/local/bin/hermes "$@" diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 5ff8efe1..6f3fb59e 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -112,11 +112,7 @@ PUBLIC_PORT=8642 # Hermes binds to 127.0.0.1 regardless of config (upstream bug). # Run it on an internal port and use socat to expose on PUBLIC_PORT. INTERNAL_PORT=18642 -# Persistent NeMo-Relay gateway port. Every Hermes process discovers it via -# NEMO_RELAY_GATEWAY_URL in env and forwards hook events to /hooks/hermes — -# replacing the per-invocation `nemo-relay hermes --` ephemeral gateway model. -# 4040 is the upstream default (NeMo-Relay crates/cli/src/config.rs:419). -NEMO_RELAY_GATEWAY_PORT=4040 +NEMO_RELAY_GATEWAY_PORT=4040 # upstream default (crates/cli/src/config.rs:419) # Hermes writes state files (PID, state.db, .channel_directory) directly into # HERMES_HOME. We cannot point it at the immutable /sandbox/.hermes dir. @@ -209,138 +205,6 @@ PYPLACEHOLDERS echo "[config] Refreshed Hermes provider placeholders from OpenShell runtime env" >&2 } -# Atomic temp-file-then-mv rewrite of an rc-file marker block. Refuses -# symlinks. Mirrors upstream NemoClaw's rewrite_rc_marker_block. -rewrite_rc_marker_block() { - local rc_file="$1" - local marker_begin="$2" - local marker_end="$3" - local snippet="${4:-}" - local dir base tmp - - [ -e "$rc_file" ] || return 0 - if [ -L "$rc_file" ] || [ ! -f "$rc_file" ]; then - echo "[SECURITY] refusing unsafe rc file: $rc_file" >&2 - return 1 - fi - - dir="$(dirname "$rc_file")" - base="$(basename "$rc_file")" - tmp="$(mktemp "${dir}/.${base}.tmp.XXXXXX")" || return 1 - - awk -v b="$marker_begin" -v e="$marker_end" \ - '$0==b{s=1;next} $0==e{s=0;next} !s' "$rc_file" >"$tmp" 2>/dev/null || { - rm -f "$tmp" - return 1 - } - - if [ -n "$snippet" ]; then - printf '%s\n' "$snippet" >>"$tmp" || { - rm -f "$tmp" - return 1 - } - fi - - if [ "$(id -u)" -eq 0 ] && ! chown root:root "$tmp"; then - rm -f "$tmp" - return 1 - fi - chmod 644 "$tmp" 2>/dev/null || true - - if [ -L "$rc_file" ]; then - echo "[SECURITY] refusing symlinked rc file during replace: $rc_file" >&2 - rm -f "$tmp" - return 1 - fi - mv -f "$tmp" "$rc_file" 2>/dev/null || { - rm -f "$tmp" - return 1 - } -} - -rewrite_rc_marker_block_or_fail_in_root() { - local rc_file="$1" - if rewrite_rc_marker_block "$@"; then - return 0 - fi - if [ "$(id -u)" -eq 0 ]; then - return 1 - fi - echo "[setup] could not update rc file ${rc_file}; continuing in non-root mode" >&2 - return 0 -} - -install_configure_guard() { - local marker_begin="# nemoclaw-configure-guard begin" - local marker_end="# nemoclaw-configure-guard end" - local snippet - # Canonical hermes() wrapper for interactive sandbox shells. Appended - # LAST to /sandbox/.bashrc (rewrite_rc_marker_block appends after any - # Dockerfile-time content), so under bash's last-define-wins this - # shadows anything earlier. Cases: - # - setup/doctor: block in-sandbox config mutations - # - gateway: pass through (start.sh runs the long-running gateway; - # user-level `hermes gateway` is unusual but supported) - # - chat/no-args: route through `nemo-relay hermes --` so the TUI - # inherits NEMO_RELAY_GATEWAY_URL and traces flow to Phoenix/ATIF - # - everything else: pass through - # - # The two HERMES_* exports below MUST live here, not in the Dockerfile - # ENV: OpenShell's exec-session env allowlist strips non-HERMES_HOME - # HERMES_* vars, so anything baked into image ENV vanishes before the - # user's shell sees it. PID-1 (start.sh-launched gateway) does get the - # Docker ENV, hence we keep HERMES_DISABLE_LAZY_INSTALLS in both - # places. HERMES_TUI_THEME is only meaningful for the TUI, so bashrc - # alone is enough. - # - HERMES_TUI_THEME=dark: short-circuits Hermes's OSC 11 background - # probe (cli.py:_is_light_mode_detected priority 2 of 6) so the - # response doesn't leak into the TUI input buffer under `openshell - # sandbox connect`'s relayed PTY. COLORFGBG (priority 4) would - # also work but is stripped by the same allowlist. - # - HERMES_DISABLE_LAZY_INSTALLS=1: stops `hermes chat`'s banner - # from hanging on `uv pip install` attempts for any optional - # feature not pre-installed via HERMES_UV_EXTRAS (the sandbox - # can't reach PyPI by policy). Lazy-install check_fns return False - # and the tool is filtered out of the registry instead. - read -r -d '' snippet <<'GUARD' || true -# nemoclaw-configure-guard begin -export HERMES_TUI_THEME=dark -export HERMES_DISABLE_LAZY_INSTALLS=1 -hermes() { - case "$1" in - setup|doctor) - echo "Error: 'hermes $1' cannot modify config inside the sandbox." >&2 - echo "NemoClaw manages sandbox config from the host for integrity checks." >&2 - echo "" >&2 - echo "To change your configuration, exit the sandbox and run:" >&2 - echo " nemoclaw onboard --resume" >&2 - return 1 - ;; - gateway) - command hermes "$@" - ;; - chat|"") - # No exec — keep the bash shell alive so Ctrl+C / TUI exit returns - # the user to their sandbox prompt instead of dropping the whole - # `openshell sandbox connect` session. - /usr/local/bin/nemo-relay hermes -- "$@" - ;; - *) - command hermes "$@" - ;; - esac -} -# nemoclaw-configure-guard end -GUARD - - for rc_file in "${_SANDBOX_HOME}/.bashrc" "${_SANDBOX_HOME}/.profile"; do - [ -f "$rc_file" ] || continue - rewrite_rc_marker_block_or_fail_in_root "$rc_file" "$marker_begin" "$marker_end" "$snippet" - done - # Lock .bashrc/.profile after all mutations are complete (best-effort in non-root). - lock_rc_files "$_SANDBOX_HOME" -} - _has_outlook_channel() { # Primary: OUTLOOK_CLIENT_ID is injected by OpenShell providers at runtime, # making it a reliable signal that the Outlook channel was configured. @@ -450,34 +314,24 @@ start_decode_proxy() { } # ── NeMo-Relay sidecar gateway ────────────────────────────────── -# Long-running standalone NeMo-Relay gateway that all Hermes processes -# (PID-1 gateway, Slack/Outlook bridge-driven turns, interactive TUI in -# Phase 2) forward hook events to via NEMO_RELAY_GATEWAY_URL. Replaces the -# old per-invocation `nemo-relay hermes -- ` ephemeral-gateway model so -# all telemetry lands in a single Phoenix/ATIF correlated session. NEMO_RELAY_PID="" start_nemo_relay_sidecar() { if ! [ -x /usr/local/bin/nemo-relay ]; then echo "[nemo-relay] binary not found at /usr/local/bin/nemo-relay, skipping" >&2 return 0 fi - # OTEL BSP tunings shape the OpenInference exporter's flush behavior; on the - # sidecar process now, not on hermes — see _export_otel_bsp_tunings comment. _export_otel_bsp_tunings if [ "$(id -u)" -eq 0 ]; then nohup gosu gateway /usr/local/bin/nemo-relay \ --bind "127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ - --plugin-config /etc/nemo-relay/plugins.toml \ >>/tmp/nemo-relay.log 2>&1 & else nohup /usr/local/bin/nemo-relay \ --bind "127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" \ - --plugin-config /etc/nemo-relay/plugins.toml \ >>/tmp/nemo-relay.log 2>&1 & fi NEMO_RELAY_PID=$! - # Wait for /healthz before returning so the PID-1 hermes launch downstream - # doesn't race the sidecar (else first-turn events drop silently). + # Wait for /healthz so PID-1 hermes doesn't race the sidecar (silent drops). local attempts=0 while [ "$attempts" -lt 30 ]; do if curl -sf "http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}/healthz" >/dev/null 2>&1; then @@ -487,8 +341,13 @@ start_nemo_relay_sidecar() { sleep 0.5 attempts=$((attempts + 1)) done - echo "[nemo-relay] WARNING: sidecar did not become healthy within 15s (pid $NEMO_RELAY_PID) — telemetry may be missing" >&2 - return 0 + # Fail-hard: silent telemetry loss is worse than a noisy startup failure. + # Surface the sidecar's own log so the operator doesn't have to dig. + echo "[nemo-relay] FATAL: sidecar did not become healthy within 15s (pid $NEMO_RELAY_PID)" >&2 + echo "[nemo-relay] --- last 30 lines of /tmp/nemo-relay.log ---" >&2 + tail -n 30 /tmp/nemo-relay.log >&2 2>/dev/null || echo "[nemo-relay] (log unreadable)" >&2 + echo "[nemo-relay] --- end log ---" >&2 + exit 1 } # Outlook bridge / MS Graph sidecar PIDs (populated at launch). @@ -601,14 +460,6 @@ export OUTLOOK_CLIENT_ID="${OUTLOOK_CLIENT_ID:-openshell:resolve:env:OUTLOOK_CLI export OUTLOOK_SESSION_UUID="${OUTLOOK_SESSION_UUID:-openshell:resolve:env:OUTLOOK_SESSION_UUID}" export MS_GRAPH_SIDECAR_URL="http://127.0.0.1:${SIDECAR_PORT}" -# Resolve sandbox home dir early — used by install_configure_guard below. -if [ "$(id -u)" -eq 0 ]; then - _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) - _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" -else - _SANDBOX_HOME="${HOME:-/sandbox}" -fi - # SECURITY FIX: Write proxy + tool env to a standalone file via # emit_sandbox_sourced_file() (root:root 444) instead of appending # inline to .bashrc/.profile. The old approach left .bashrc writable @@ -632,6 +483,9 @@ export OUTLOOK_CLIENT_ID="${OUTLOOK_CLIENT_ID:-openshell:resolve:env:OUTLOOK_CLI export OUTLOOK_SESSION_UUID="${OUTLOOK_SESSION_UUID:-openshell:resolve:env:OUTLOOK_SESSION_UUID}" export MS_GRAPH_SIDECAR_URL="http://127.0.0.1:${SIDECAR_PORT}" export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:${NEMO_RELAY_GATEWAY_PORT}" +export PATH="/usr/local/lib/nemoclaw/bin:\$PATH" +export HERMES_TUI_THEME=dark +export HERMES_DISABLE_LAZY_INSTALLS=1 PROXYEOF for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO; do _ca_env_value="${!_ca_env_name:-}" @@ -657,7 +511,6 @@ if [ "$(id -u)" -ne 0 ]; then fi deploy_config_to_writable refresh_hermes_provider_placeholders - install_configure_guard configure_messaging_channels if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then @@ -682,8 +535,7 @@ if [ "$(id -u)" -ne 0 ]; then echo "[nemo-relay] WARNING: binary or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi - # Start the NeMo-Relay sidecar BEFORE PID-1 hermes, so the gateway's first - # turn finds NEMO_RELAY_GATEWAY_URL reachable and doesn't drop hook events. + # Sidecar must be healthy before PID-1 hermes starts (else first-turn drops). start_nemo_relay_sidecar HERMES_HOME="${HERMES_WRITABLE}" \ @@ -726,7 +578,6 @@ fi verify_config_integrity "${HERMES_IMMUTABLE}" "${HERMES_HASH_FILE}" deploy_config_to_writable refresh_hermes_provider_placeholders -install_configure_guard configure_messaging_channels if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then @@ -758,17 +609,12 @@ validate_config_symlinks "${HERMES_IMMUTABLE}" "${HERMES_WRITABLE}" # Lock .hermes directory after validation. harden_config_symlinks "${HERMES_IMMUTABLE}" "hermes" -# Start the NeMo-Relay sidecar (as 'gateway' user) BEFORE PID-1 hermes, so -# the gateway's first turn finds NEMO_RELAY_GATEWAY_URL reachable and -# doesn't drop hook events. start_nemo_relay_sidecar waits on /healthz. +# Sidecar must be healthy before PID-1 hermes starts (else first-turn drops). start_nemo_relay_sidecar -# Start Hermes as the 'gateway' user. NEMO_RELAY_GATEWAY_URL points at the -# sidecar started above; Hermes's nemo-relay plugin reads the env var at -# request time and POSTs hook events to /hooks/hermes on the sidecar. -# Slack/Outlook bridge-driven turns inherit this env var via the explicit -# launch list (the bridges themselves don't emit telemetry, but their -# messages funnel into this PID-1 process which does). +# NEMO_RELAY_GATEWAY_URL must be in the explicit launch env — PID-1 hermes +# does not read _PROXY_ENV_FILE, and Slack/Outlook bridge-driven turns funnel +# through this process to emit telemetry. HERMES_HOME="${HERMES_WRITABLE}" \ HTTPS_PROXY="${_PROXY_URL}" \ HTTP_PROXY="${_PROXY_URL}" \ From dfbdd09cc07957998709b5d08ba9a9a089865fe5 Mon Sep 17 00:00:00 2001 From: Matt Penn Date: Tue, 26 May 2026 05:00:34 +0000 Subject: [PATCH 10/10] Refactor Hermes integration for improved session finalization and clarity - Updated the Dockerfile to replace the `finalize-shim` with a new `finalize-hook` script, enhancing the handling of session finalization events. - Improved comments in the Dockerfile and related scripts for better understanding of NeMo-Relay's interaction with Hermes. - Adjusted the `generate-config.ts` to reflect the new finalize hook and ensure proper event handling. - Streamlined the `start.sh` script by removing references to the obsolete config.toml, focusing on the necessary plugins.toml for observability. Signed-off-by: Matt Penn --- .../agents/hermes/Dockerfile | 34 ++++++-------- .../agents/hermes/generate-config.ts | 13 +++--- .../agents/hermes/manifest.yaml | 2 +- .../{finalize-shim => finalize-hook} | 16 +++---- .../hermes/plugins/nemo-relay/__init__.py | 29 ++++++------ .../hermes/plugins/nemoclaw/__init__.py | 46 +------------------ .../agents/hermes/start.sh | 10 ++-- 7 files changed, 52 insertions(+), 98 deletions(-) rename examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/{finalize-shim => finalize-hook} (74%) diff --git a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile index 9a1049af..1ce80ef5 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile +++ b/examples/personal-community-sentiment-triage/agents/hermes/Dockerfile @@ -178,18 +178,20 @@ RUN set -eu \ COPY --from=nemo-relay-builder /out/bin/nemo-relay /usr/local/bin/nemo-relay RUN chmod 755 /usr/local/bin/nemo-relay -# Per-turn finalize shim. Hermes only fires `on_session_finalize` from its +RUN mkdir -p /usr/local/lib/nemoclaw/bin + +# Per-turn finalize hook. Hermes only fires `on_session_finalize` from its # idle-session expiry watcher (~5 min default), but NeMo-Relay's ATIF writer # and root-span closer only act on `on_session_finalize` / `on_session_reset`. -# This shim, registered as a second hook on `on_session_end`, rewrites the -# event name and re-posts to the gateway so each turn closes its agent scope. -COPY agents/hermes/nemo-relay/finalize-shim /usr/local/bin/nemo-relay-finalize-shim -RUN chmod 755 /usr/local/bin/nemo-relay-finalize-shim +# This hook handler, registered as a second command on `on_session_end`, +# rewrites the event name and re-posts to the gateway so each turn closes +# its agent scope. +COPY agents/hermes/nemo-relay/finalize-hook /usr/local/lib/nemoclaw/bin/nemo-relay-finalize-hook +RUN chmod 755 /usr/local/lib/nemoclaw/bin/nemo-relay-finalize-hook # Interactive-shell shim for `hermes`. Prepended to PATH for sandbox shells # (via _PROXY_ENV_FILE in start.sh) so `hermes` resolves here first; blocks # setup/doctor in-sandbox and execs the upstream binary for everything else. -RUN mkdir -p /usr/local/lib/nemoclaw/bin COPY agents/hermes/nemo-relay/hermes-cli-shim /usr/local/lib/nemoclaw/bin/hermes RUN chmod 755 /usr/local/lib/nemoclaw/bin/hermes @@ -209,22 +211,16 @@ RUN pip3 install --no-cache-dir --break-system-packages \ # ── NeMo-Relay plugin config (immutable, baked at build time) ──────────── # Discovery order: /etc/nemo-relay → project ./.nemo-relay → user XDG -# (NeMo-Relay crates/cli/src/config.rs:618-633). /etc/ is the cleanest -# location for a containerized agent because it ignores CWD. +# (see NeMo-Relay crates/cli/src/config.rs). /etc/ is the cleanest location +# for a containerized agent because it ignores CWD. # -# Two files are baked here: -# config.toml — `[agents.hermes]` block so `nemo-relay hermes` finds a -# configured agent and skips the interactive setup wizard -# (which fails in non-TTY sandboxes). -# plugins.toml — observability component (ATIF + OpenInference). +# Only plugins.toml is baked — observability component (ATIF + OpenInference). +# The daemon doesn't need a config.toml here: --bind is passed explicitly by +# start.sh, and the wrapped-CLI `[agents.hermes]` block isn't used in the +# sidecar/daemon architecture (agents POST to the daemon; it doesn't launch +# them). COPY agents/hermes/nemo-relay/plugins.toml.in /tmp/nemo-relay-plugins.toml.in RUN mkdir -p /etc/nemo-relay \ - && printf '%s\n' \ - '[agents.hermes]' \ - 'command = "hermes"' \ - 'hooks_path = "/sandbox/.hermes/config.yaml"' \ - > /etc/nemo-relay/config.toml \ - && chmod 444 /etc/nemo-relay/config.toml \ && PHOENIX_URL="${PHOENIX_COLLECTOR_ENDPOINT:-}" \ && if [ -z "$PHOENIX_URL" ]; then \ PHOENIX_ENABLED=false; PHOENIX_ENDPOINT=""; \ diff --git a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts index ae04f5f6..243abfa3 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts +++ b/examples/personal-community-sentiment-triage/agents/hermes/generate-config.ts @@ -115,7 +115,8 @@ function main(): void { }, // NeMo-Relay shell hooks — each event spawns `nemo-relay hook-forward hermes`, // which reads the JSON payload from stdin and POSTs it to NEMO_RELAY_GATEWAY_URL - // (injected by the `nemo-relay hermes` wrapper). Events are the intersection of + // (exported by start.sh into PID-1 hermes's launch env, pointing at the + // persistent sidecar gateway on 127.0.0.1:4040). Events are the intersection of // NeMo-Relay's HERMES_HOOK_EVENTS (installer.rs) and Hermes's VALID_HOOKS // (hermes_cli/plugins.py). NeMo-Relay's "api_request_error" and "subagent_start" // are forward-looking — current Hermes only exposes "subagent_stop" and reports @@ -127,7 +128,7 @@ function main(): void { // (plugins/nemo-relay/) owns those events under Hermes v0.14.0: it // receives the real `request_messages` list and the real `response` SDK // object as kwargs for api_request, and synthesizes stable tool_call_ids - // for tool_call events to work around NeMo-Relay's adapters/mod.rs:231-247 + // for tool_call events to work around NeMo-Relay's adapters/mod.rs // synthesizing a fresh UUID per call when Hermes' defensive // `tool_call_id or ""` strips the id. The plugin forwards everything to // NEMO_RELAY_GATEWAY_URL/hooks/hermes with payload.request.body / @@ -137,22 +138,22 @@ function main(): void { // same events alongside the plugin would create duplicate lossy-summary // scopes on the gateway. // - // `on_session_end` gets a SECOND command (`nemo-relay-finalize-shim`) that + // `on_session_end` gets a SECOND command (`nemo-relay-finalize-hook`) that // synthesizes a per-turn `on_session_finalize`. Hermes fires real finalize // only from its idle-session expiry watcher (~5 min default), but // NeMo-Relay's ATIF writer and root-span closer only act on finalize. The - // shim closes the agent scope every turn so each conversation produces a + // hook closes the agent scope every turn so each conversation produces a // complete Phoenix root span and a fresh ATIF JSON file. hooks: (() => { const fwd = { command: "/usr/local/bin/nemo-relay hook-forward hermes", timeout: 30 }; - const finalize_shim = { command: "/usr/local/bin/nemo-relay-finalize-shim", timeout: 30 }; + const finalize_hook = { command: "/usr/local/lib/nemoclaw/bin/nemo-relay-finalize-hook", timeout: 30 }; const events = [ "on_session_start", "on_session_finalize", "on_session_reset", "pre_llm_call", "post_llm_call", "subagent_stop", ]; const result: Record = Object.fromEntries(events.map((ev) => [ev, [fwd]])); - result.on_session_end = [fwd, finalize_shim]; + result.on_session_end = [fwd, finalize_hook]; return result; })(), // Auto-accept the hook commands. The sandbox is non-interactive; without diff --git a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml index b8e30fe6..36746a21 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml +++ b/examples/personal-community-sentiment-triage/agents/hermes/manifest.yaml @@ -17,7 +17,7 @@ install_method: curl # curl install.sh | bash binary_path: /usr/local/bin/hermes version_command: "hermes --version" expected_version: "2026.4.8" -gateway_command: "hermes gateway run" # entrypoint wraps this with `nemo-relay hermes --` (see start.sh) +gateway_command: "hermes gateway run" # start.sh launches this with NEMO_RELAY_GATEWAY_URL in env, pointing at the sidecar # ── Health probe ──────────────────────────────────────────────── # The API server adapter listens on 8642 by default and exposes diff --git a/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-hook similarity index 74% rename from examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim rename to examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-hook index 1a38d87c..5e07c749 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-shim +++ b/examples/personal-community-sentiment-triage/agents/hermes/nemo-relay/finalize-hook @@ -2,23 +2,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# NeMo-Relay gateway-mode finalize shim. +# NeMo-Relay gateway-mode per-turn finalize hook. # # Hermes fires `on_session_end` at every `run_conversation` turn boundary but # only fires `on_session_finalize` from its idle-session expiry watcher every # ~5 minutes. NeMo-Relay 0.3.0's gateway adapter (crates/cli/src/adapters/ -# hermes.rs:52-64) maps `on_session_end` to `TurnEnded`, which is documented +# hermes.rs) maps `on_session_end` to `TurnEnded`, which is documented # as NOT closing the agent scope — and the ATIF writer in # crates/core/src/observability/plugin_component.rs (write_atif_file callers) # only writes when the agent scope closes. Result: orphaned Phoenix spans and # no ATIF files per turn. # -# This shim is registered as a SECOND command on `on_session_end`. It rewrites -# the JSON payload's `hook_event_name` to `on_session_finalize` and pipes it -# back into `nemo-relay hook-forward hermes`, so the gateway closes the agent -# scope and writes ATIF every turn. The first command (the unmodified -# `hook-forward`) still delivers the on_session_end event for any other -# downstream handling. +# This handler is registered as a SECOND command on `on_session_end`. It +# rewrites the JSON payload's `hook_event_name` to `on_session_finalize` and +# pipes it back into `nemo-relay hook-forward hermes`, so the gateway closes +# the agent scope and writes ATIF every turn. The first command (the +# unmodified `hook-forward`) still delivers the on_session_end event for any +# other downstream handling. import json import os import subprocess diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py index 761ff843..3a9e0c3f 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemo-relay/__init__.py @@ -17,8 +17,10 @@ prompt+completion when it finds `payload.request.body` (pre) or `payload.response.{raw_response,choices,assistant_message}` (post). This plugin builds those payload shapes from the in-process kwargs and POSTs them to -`${NEMO_RELAY_GATEWAY_URL}/hooks/hermes` — the URL is exported into the Hermes -child env by the `nemo-relay hermes -- gateway run` wrapper at start.sh. +`${NEMO_RELAY_GATEWAY_URL}/hooks/hermes` — the URL is exported by start.sh +into PID-1 hermes's launch env and into `_PROXY_ENV_FILE` (sourced by +`/sandbox/.bashrc` for interactive shells), pointing at the persistent +sidecar gateway on `127.0.0.1:4040`. Failure mode is fail-open: any exception is swallowed and logged at debug. Hermes turns must never break because the bridge can't reach NeMo-Relay. @@ -51,8 +53,8 @@ # FIFO of synthesized tool_call_ids keyed by (task_id, tool_name). The key # uses task_id (not session_id) because Hermes' pre/post call sites are -# asymmetric: agent_runtime_helpers.py:1500-1503 fires pre_tool_call without -# passing session_id (defaults to ""), while model_tools.py:851-859 fires +# asymmetric: agent_runtime_helpers.py fires pre_tool_call without +# passing session_id (defaults to ""), while model_tools.py fires # post_tool_call with the real session_id. task_id and tool_name are passed # consistently to both, so they form a stable join key. post_tool_call pops # from the matching queue to pair with the right pre. @@ -84,8 +86,9 @@ def _gateway_url() -> Optional[str]: if not _DISABLED_LOGGED: logger.debug( "nemo-relay: NEMO_RELAY_GATEWAY_URL is not set; " - "bridge will not forward hooks (expected when Hermes " - "runs outside `nemo-relay hermes -- ...`)." + "bridge will not forward hooks. start.sh exports this " + "for PID-1 hermes and interactive shells — a missing " + "value in either context indicates a misconfiguration." ) _DISABLED_LOGGED = True return None @@ -160,7 +163,7 @@ def _coerce_request_messages( """Hermes v0.14.0 passes a real `request_messages` list of {role, content} dicts. Fall back to conversation_history, then synthesize a single user message from user_message — mirrors Langfuse's resolver at - plugins/observability/langfuse/__init__.py:409.""" + plugins/observability/langfuse/__init__.py.""" for candidate in (request_messages, conversation_history): if isinstance(candidate, list) and candidate: return candidate @@ -192,7 +195,7 @@ def _serialize_tool_calls(tool_calls: Any) -> list: def _serialize_assistant_message(obj: Any) -> Optional[dict]: """Pull the fields NeMo-Relay's adapter inspects on - `response.assistant_message` (adapters/hermes.rs:264-280).""" + `response.assistant_message` (adapters/hermes.rs).""" if obj is None: return None if isinstance(obj, dict): @@ -207,7 +210,7 @@ def _serialize_assistant_message(obj: Any) -> Optional[dict]: def _serialize_response_object(response: Any) -> Optional[dict]: """Turn the v0.14.0 `response=` kwarg (a SimpleNamespace with .choices/.usage/.model/.id) into a dict. The result has `choices` at - top-level, which adapters/hermes.rs:251-256 recognizes as a real + top-level, which adapters/hermes.rs recognizes as a real provider response and uses to mark provider_payload_exact=true.""" if response is None: return None @@ -288,8 +291,8 @@ def _stable_tool_call_id( and the gateway pairs them into a single Phoenix span. task_id (not session_id) is in the digest because Hermes' pre call site - at agent_runtime_helpers.py:1500-1503 doesn't pass session_id (defaults - to ""), while the post call site at model_tools.py:851-859 does — so + at agent_runtime_helpers.py doesn't pass session_id (defaults + to ""), while the post call site at model_tools.py does — so including session_id would make pre's hash differ from post's. task_id is passed to both call sites and is unique per turn. """ @@ -400,10 +403,10 @@ def on_post_tool_call(**kwargs: Any) -> None: tool_name = kwargs.get("tool_name") or "" args = kwargs.get("args") # Hermes' tool-dispatch path fires pre_tool_call with tool_call_id="" - # (agent_runtime_helpers.py:1500-1503 calls + # (agent_runtime_helpers.py calls # get_pre_tool_call_block_message without passing tool_call_id or # session_id) and post_tool_call with the real provider id and the - # real session_id (model_tools.py:851-859). The pre already shipped a + # real session_id (model_tools.py). The pre already shipped a # synthesized id to the gateway; we must echo the SAME id at post or # the gateway adapter treats them as two unpaired spans. FIFO key is # (task_id, tool_name) since those two are the only fields passed diff --git a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py index 0c9fca20..84a76917 100644 --- a/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py +++ b/examples/personal-community-sentiment-triage/agents/hermes/plugins/nemoclaw/__init__.py @@ -17,7 +17,6 @@ import json import os import subprocess -import sys import yaml @@ -91,47 +90,13 @@ def _get_sandbox_info(): } -def _build_banner(info): - # No Gateway field: at register() time Hermes's API server isn't up - # yet, so the live health check would always report "stopped". - lines = [ - "NemoClaw registered (Hermes)", - "", - f"Model: {info['model']}", - f"Provider: {info['provider']}", - "Tools: nemoclaw_status, nemoclaw_info,", - " nemoclaw_reload_skills", - ] - inner = max(len(line) for line in lines) - horizontal = "─" * (inner + 2) - - # Border in palette green, TTY-gated and NO_COLOR-respecting — matches - # NeMo Relay's launcher.rs:eprint_border_line. - use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR") - if use_color: - green, reset = "\x1b[38;5;112m", "\x1b[0m" - top = f"{green}╭{horizontal}╮{reset}" - bot = f"{green}╰{horizontal}╯{reset}" - pipe = f"{green}│{reset}" - else: - top = f"╭{horizontal}╮" - bot = f"╰{horizontal}╯" - pipe = "│" - - rows = ["", top] - for line in lines: - rows.append(f"{pipe} {line.ljust(inner)} {pipe}") - rows.append(bot) - return "\n".join(rows) - - def _handle_status(tool_input, **kwargs): """Handle the nemoclaw_status tool call. The ``**kwargs`` swallows the context fields (``task_id``, ``session_id``, ``tool_call_id``, ``parent_agent``) that ``tools/registry.dispatch`` forwards to every handler — see ``handler(args, **kwargs)`` at - ``tools/registry.py:306``. Without this, calls fail with + ``tools/registry.py``. Without this, calls fail with ``TypeError: got an unexpected keyword argument 'task_id'`` and the tool surfaces an error to the user instead of running. """ @@ -265,12 +230,3 @@ def _on_session_start(**kwargs): _reload_skills() ctx.register_hook("on_session_start", _on_session_start) - - # Print at register() time, not from on_session_start: on_session_start - # fires inside run_conversation and routes the banner into the first - # user-message frame. try/except so a config-read failure can't block - # tool registration above. - try: - print(_build_banner(_get_sandbox_info())) - except Exception: - pass diff --git a/examples/personal-community-sentiment-triage/agents/hermes/start.sh b/examples/personal-community-sentiment-triage/agents/hermes/start.sh index 6f3fb59e..ad55223c 100755 --- a/examples/personal-community-sentiment-triage/agents/hermes/start.sh +++ b/examples/personal-community-sentiment-triage/agents/hermes/start.sh @@ -112,7 +112,7 @@ PUBLIC_PORT=8642 # Hermes binds to 127.0.0.1 regardless of config (upstream bug). # Run it on an internal port and use socat to expose on PUBLIC_PORT. INTERNAL_PORT=18642 -NEMO_RELAY_GATEWAY_PORT=4040 # upstream default (crates/cli/src/config.rs:419) +NEMO_RELAY_GATEWAY_PORT=4040 # upstream default (crates/cli/src/config.rs) # Hermes writes state files (PID, state.db, .channel_directory) directly into # HERMES_HOME. We cannot point it at the immutable /sandbox/.hermes dir. @@ -244,7 +244,7 @@ start_gateway_log_stream() { # Force the OpenInference BatchSpanProcessor (OpenTelemetry SDK 0.31, used # by nemo-relay's HTTP exporter at -# crates/core/src/observability/openinference.rs:449-460) to flush every +# crates/core/src/observability/openinference.rs) to flush every # span immediately instead of batching for 5 seconds. Without these, real # multi-scope Hermes turns produce a single larger POST that the OpenShell # L7 proxy appears to acknowledge with 200 but not forward to Phoenix, @@ -527,10 +527,8 @@ if [ "$(id -u)" -ne 0 ]; then mkdir -p /tmp/atif # NeMo-Relay observability is configured via /etc/nemo-relay/plugins.toml # (baked at image build time). Verify the binary and config are present. - if [ -x /usr/local/bin/nemo-relay ] \ - && [ -r /etc/nemo-relay/config.toml ] \ - && [ -r /etc/nemo-relay/plugins.toml ]; then - echo "[nemo-relay] binary + config present (config.toml + plugins.toml in /etc/nemo-relay)" | tee -a /tmp/gateway.log >&2 + if [ -x /usr/local/bin/nemo-relay ] && [ -r /etc/nemo-relay/plugins.toml ]; then + echo "[nemo-relay] binary + config present (plugins.toml=/etc/nemo-relay/plugins.toml)" | tee -a /tmp/gateway.log >&2 else echo "[nemo-relay] WARNING: binary or config missing — telemetry disabled" | tee -a /tmp/gateway.log >&2 fi