Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 151 additions & 18 deletions examples/hermes_sentinel/hermes_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,39 @@

from __future__ import annotations

import json
import os
import sys
import uuid
from pathlib import Path

# ─── AX_GATEWAY_EVENT protocol ────────────────────────────────────────────
# Lines prefixed with `AX_GATEWAY_EVENT ` on stdout are parsed by the Gateway
# and forwarded as platform `agent_processing` / tool-call events so the UI
# sees per-mention phase text while the agent is still working. Unprefixed
# stdout lines accumulate into the final reply body.
EVENT_PREFIX = "AX_GATEWAY_EVENT "

# Preserve the *original* stdout before we suppress hermes's internal prints.
# AX_GATEWAY_EVENT lines and the final reply must reach Gateway, but hermes's
# chatter ("deliberating...", tool previews, etc.) must not — otherwise the
# Gateway captures it all as the reply body. `_real_stdout` is the escape hatch
# for our own prints.
_real_stdout = sys.stdout


def _emit_event(payload: dict) -> None:
_real_stdout.write(f"{EVENT_PREFIX}{json.dumps(payload, sort_keys=True)}\n")
_real_stdout.flush()


def _print_reply(text: str) -> None:
_real_stdout.write(text + "\n")
_real_stdout.flush()


# ─── Resolve hermes-agent location ─────────────────────────────────────────
HERMES_REPO = Path(
os.environ.get("HERMES_REPO_PATH", str(Path.home() / "hermes-agent"))
).expanduser()
HERMES_REPO = Path(os.environ.get("HERMES_REPO_PATH", str(Path.home() / "hermes-agent"))).expanduser()

if not HERMES_REPO.exists():
print(
Expand Down Expand Up @@ -120,9 +145,7 @@ def _resolve_provider(model: str) -> dict:
return {
"provider": "anthropic",
"api_mode": "anthropic_messages",
"base_url": os.environ.get(
"ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1"
),
"base_url": os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com/v1"),
"api_key": os.environ.get("ANTHROPIC_API_KEY", ""),
"model": name,
}
Expand All @@ -141,14 +164,13 @@ def _resolve_provider(model: str) -> dict:
if not codex_key:
# Fall back to ~/.hermes/auth.json (the format hermes-cli maintains).
import json

auth_path = Path.home() / ".hermes" / "auth.json"
if auth_path.exists():
try:
data = json.loads(auth_path.read_text())
providers = data.get("providers") or {}
active = data.get("active_provider") or next(
iter(providers.keys()), None
)
active = data.get("active_provider") or next(iter(providers.keys()), None)
tokens = (providers.get(active) or {}).get("tokens") or {}
codex_key = tokens.get("access_token", "")
except (OSError, json.JSONDecodeError):
Expand Down Expand Up @@ -200,6 +222,81 @@ def main() -> int:
# Change to workdir so relative file tool paths behave predictably.
os.chdir(workdir)

# ─── Phase events for Gateway/UI ───────────────────────────────────────
# `tool_progress_callback` is an AIAgent constructor param (see
# hermes_agent/run_agent.py:446). It fires before each tool call with
# (name, args_preview, args_dict). We translate those into
# AX_GATEWAY_EVENT tool_start/tool_result pairs so the UI chip
# reflects what Hermes is actually doing.
#
# `activity` is the human-readable chip text (<40 chars). Extracting
# a salient arg per tool makes the bubble read
# "@X: $ pwd" or "@X: reading AGENTS.md"
# instead of the generic "@X is using terminal…".

def _tool_activity(tool_name: str, args: dict | None) -> str:
args = args if isinstance(args, dict) else {}
name = (tool_name or "").lower()
# Normalize synonyms across hermes's tool namespace
if name in {"terminal", "bash", "shell", "run_command"}:
cmd = args.get("command") or args.get("cmd") or ""
cmd = str(cmd).splitlines()[0].strip() if cmd else ""
return f"$ {cmd}"[:80] if cmd else "running shell"
if name in {"read_file", "read", "view"}:
path = args.get("path") or args.get("file") or args.get("filename") or ""
return f"reading {path}"[:80] if path else "reading file"
if name in {"write_file", "write", "create_file"}:
path = args.get("path") or args.get("file") or args.get("filename") or ""
return f"writing {path}"[:80] if path else "writing file"
if name in {"edit_file", "edit", "str_replace"}:
path = args.get("path") or args.get("file") or ""
return f"editing {path}"[:80] if path else "editing file"
if name in {"search", "grep", "find"}:
pattern = args.get("pattern") or args.get("query") or args.get("q") or ""
return f"searching {pattern}"[:80] if pattern else "searching"
if name in {"web_search", "search_web"}:
q = args.get("query") or args.get("q") or ""
return f"web search: {q}"[:80] if q else "web search"
if name in {"fetch", "http_get", "url"}:
url = args.get("url") or args.get("target") or ""
return f"fetching {url}"[:80] if url else "fetching"
# Generic fallback: surface the first scalar arg value
for k, v in args.items():
if isinstance(v, (str, int, float, bool)):
return f"{tool_name}: {str(v)}"[:80]
return f"using {tool_name}"[:80]

def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None):
tool_call_id = f"hermes-{uuid.uuid4()}"
args = args_dict if isinstance(args_dict, dict) else {}
activity = _tool_activity(tool_name, args)
try:
_emit_event(
{
"kind": "tool_start",
"tool_name": tool_name,
"tool_action": tool_name,
"tool_call_id": tool_call_id,
"status": "tool_call",
"arguments": args,
"activity": activity,
"message": activity,
}
)
_emit_event(
{
"kind": "tool_result",
"tool_name": tool_name,
"tool_action": tool_name,
"tool_call_id": tool_call_id,
"status": "tool_complete",
"activity": activity,
"message": activity,
}
)
except Exception:
pass # never let event emission break the agent run

agent = AIAgent(
base_url=cfg["base_url"],
api_key=cfg["api_key"],
Expand All @@ -210,26 +307,62 @@ def main() -> int:
tool_delay=0.5,
quiet_mode=True,
skip_context_files=True, # explicit prompt only — no auto CLAUDE.md
skip_memory=True, # see Design notes in module docstring
skip_memory=True, # see Design notes in module docstring
disabled_toolsets=[
"web", "browser", "image_generation", "tts", "vision",
"cronjob", "rl_training", "homeassistant",
"web",
"browser",
"image_generation",
"tts",
"vision",
"cronjob",
"rl_training",
"homeassistant",
],
tool_progress_callback=_on_tool_progress,
)

_emit_event({"kind": "status", "status": "started", "message": "Agent planning"})
_emit_event({"kind": "status", "status": "thinking", "message": "Thinking"})

# ─── Run a single conversation turn ────────────────────────────────────
result = agent.run_conversation(
user_message=content,
system_message=system_prompt,
)
# Hermes's AIAgent prints progress chatter ("deliberating...", tool
# previews, "🔧 ..." etc.) to stdout even with quiet_mode=True. If we
# don't suppress it, Gateway captures all of it as the reply body.
# Redirect sys.stdout/sys.stderr to devnull during the run; our own
# AX_GATEWAY_EVENT emissions write to `_real_stdout` directly and
# still reach Gateway.
saved_stdout = sys.stdout
saved_stderr = sys.stderr
devnull = open(os.devnull, "w")
sys.stdout = devnull
sys.stderr = devnull
try:
try:
result = agent.run_conversation(
user_message=content,
system_message=system_prompt,
)
except Exception as run_err:
sys.stdout = saved_stdout
sys.stderr = saved_stderr
_emit_event({"kind": "status", "status": "error", "message": f"Agent error: {run_err}"[:200]})
print(f"Hermes bridge failed: {run_err}", file=saved_stderr)
return 1
finally:
sys.stdout = saved_stdout
sys.stderr = saved_stderr
devnull.close()

final_text = result.get("final_response", "").strip()
if not final_text:
_emit_event({"kind": "status", "status": "error", "message": "Agent produced no output"})
print("(agent produced no output)", file=sys.stderr)
return 1

# ax listen captures stdout → posts to aX as the agent's reply.
print(final_text)
_emit_event({"kind": "status", "status": "completed", "message": "Reply ready"})

# Gateway captures unprefixed stdout lines → posts to aX as the reply.
_print_reply(final_text)
return 0


Expand Down