From d5ac61cb98bd911ffa17585d2e6d8075ab3bc75a Mon Sep 17 00:00:00 2001 From: anvil Date: Wed, 22 Apr 2026 02:48:49 +0000 Subject: [PATCH 1/3] feat(examples/hermes): emit AX_GATEWAY_EVENT phase events for tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes_sentinel bridge was final-reply-only: no tool_call, no status, no AX_GATEWAY_EVENT lines, so Gateway-managed hermes runtimes produced blank phase bubbles in the UI even though the underlying AIAgent emits tool_progress callbacks internally. This pipes hermes's `tool_progress_callback` (AIAgent constructor param, fires before each tool with (name, args_preview, args_dict)) into AX_GATEWAY_EVENT `tool_start` / `tool_result` pairs so the UI chip shows what the agent is doing in real time. Also emits `status` events for started → thinking → completed / error phases wrapping the run, and normalizes any run_conversation exception into a clean `error` status event instead of letting the traceback escape to stderr (which Gateway would otherwise post as the reply body). Protocol consumed by Gateway runtime (see ax_cli/gateway.py): - `AX_GATEWAY_EVENT {"kind":"status","status":"...","message":"..."}` - `AX_GATEWAY_EVENT {"kind":"tool_start","tool_name":"...","tool_call_id":"...","status":"tool_call",...}` - `AX_GATEWAY_EVENT {"kind":"tool_result","tool_name":"...","tool_call_id":"...","status":"tool_complete",...}` Unprefixed stdout continues to accumulate into the final reply body. Validated on dev.paxai.app: dev_sentinel registered with this bridge, phase sequence reaches the pending bubble via agent_processing SSE. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/hermes_sentinel/hermes_bridge.py | 65 +++++++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/examples/hermes_sentinel/hermes_bridge.py b/examples/hermes_sentinel/hermes_bridge.py index 01df109..0f2c419 100755 --- a/examples/hermes_sentinel/hermes_bridge.py +++ b/examples/hermes_sentinel/hermes_bridge.py @@ -64,10 +64,23 @@ 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 " + + +def _emit_event(payload: dict) -> None: + print(f"{EVENT_PREFIX}{json.dumps(payload, sort_keys=True)}", flush=True) + # ─── Resolve hermes-agent location ───────────────────────────────────────── HERMES_REPO = Path( os.environ.get("HERMES_REPO_PATH", str(Path.home() / "hermes-agent")) @@ -200,6 +213,35 @@ 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. + def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None): + tool_call_id = f"hermes-{uuid.uuid4()}" + 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_dict if isinstance(args_dict, dict) else {}, + "message": f"Using {tool_name}", + }) + _emit_event({ + "kind": "tool_result", + "tool_name": tool_name, + "tool_action": tool_name, + "tool_call_id": tool_call_id, + "status": "tool_complete", + "message": f"{tool_name} in progress", + }) + except Exception: + pass # never let event emission break the agent run + agent = AIAgent( base_url=cfg["base_url"], api_key=cfg["api_key"], @@ -215,20 +257,33 @@ def main() -> int: "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, - ) + try: + result = agent.run_conversation( + user_message=content, + system_message=system_prompt, + ) + except Exception as run_err: + _emit_event({"kind": "status", "status": "error", "message": f"Agent error: {run_err}"[:200]}) + print(f"Hermes bridge failed: {run_err}", file=sys.stderr) + return 1 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. + _emit_event({"kind": "status", "status": "completed", "message": "Reply ready"}) + + # Gateway captures the tail of stdout (unprefixed lines) → posts to aX as + # the agent's reply. print(final_text) return 0 From c7d904af8eb252b9610d2e01bd10239cbaec3e04 Mon Sep 17 00:00:00 2001 From: anvil Date: Wed, 22 Apr 2026 02:51:49 +0000 Subject: [PATCH 2/3] fix(examples/hermes): redirect hermes stdout/stderr during run so terminal chatter doesn't leak into reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes's AIAgent prints progress animations ("deliberating...", tool previews, "💻 $ pwd", "ಠ_ಠ computing...", etc.) to stdout even with quiet_mode=True. Gateway captures unprefixed stdout as the reply body, so the bubble was receiving Hermes's terminal chatter instead of the actual final_response — e.g. "[tool] ٩(๑❛ᴗ❛๑)۶ deliberating... ┊ 💻 $ pwd 7.4s" as the user-visible reply. Fix: capture _real_stdout before the run, redirect sys.stdout/sys.stderr to /dev/null during run_conversation, emit AX_GATEWAY_EVENT lines and the final reply via a dedicated writer bound to _real_stdout. Clean separation: hermes's internal prints get swallowed, our phase events and the real reply reach Gateway exactly once. Validated on dev.paxai.app (dev_sentinel msg bf2c87b6): reply_sent | pong cwd: `/home/ax-agent/agents/dev_sentinel` Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/hermes_sentinel/hermes_bridge.py | 54 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/examples/hermes_sentinel/hermes_bridge.py b/examples/hermes_sentinel/hermes_bridge.py index 0f2c419..8ef0e2e 100755 --- a/examples/hermes_sentinel/hermes_bridge.py +++ b/examples/hermes_sentinel/hermes_bridge.py @@ -77,9 +77,22 @@ # 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: - print(f"{EVENT_PREFIX}{json.dumps(payload, sort_keys=True)}", flush=True) + _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( @@ -264,15 +277,33 @@ def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None): _emit_event({"kind": "status", "status": "thinking", "message": "Thinking"}) # ─── Run a single conversation turn ──────────────────────────────────── + # 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: - result = agent.run_conversation( - user_message=content, - system_message=system_prompt, - ) - except Exception as run_err: - _emit_event({"kind": "status", "status": "error", "message": f"Agent error: {run_err}"[:200]}) - print(f"Hermes bridge failed: {run_err}", file=sys.stderr) - return 1 + 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: @@ -282,9 +313,8 @@ def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None): _emit_event({"kind": "status", "status": "completed", "message": "Reply ready"}) - # Gateway captures the tail of stdout (unprefixed lines) → posts to aX as - # the agent's reply. - print(final_text) + # Gateway captures unprefixed stdout lines → posts to aX as the reply. + _print_reply(final_text) return 0 From 058c6f241664eb67df06075091044e59893fa576 Mon Sep 17 00:00:00 2001 From: anvil Date: Wed, 22 Apr 2026 02:57:13 +0000 Subject: [PATCH 3/3] feat(examples/hermes): extract salient tool args into activity text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of chip reading a generic "@X is using terminal...", surface the specific command/file so the user sees what the agent is actually doing: terminal + {command:"pwd"} → "$ pwd" read_file + {path:"AGENTS.md"} → "reading AGENTS.md" write_file + {path:"notes.md"} → "writing notes.md" edit_file → "editing " search/grep → "searching " web_search → "web search: " fetch → "fetching " unknown tool → first scalar arg or "using " Truncated to 80 chars at the emitter so the chip stays short. Validated on dev.paxai.app (msg 71d14a1b multi-tool run): activity="reading /home/ax-agent/agents/dev_sentinel/AGENTS.md" activity="$ pwd" Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/hermes_sentinel/hermes_bridge.py | 48 +++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/examples/hermes_sentinel/hermes_bridge.py b/examples/hermes_sentinel/hermes_bridge.py index 8ef0e2e..5cdd90a 100755 --- a/examples/hermes_sentinel/hermes_bridge.py +++ b/examples/hermes_sentinel/hermes_bridge.py @@ -232,8 +232,48 @@ def main() -> int: # (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", @@ -241,8 +281,9 @@ def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None): "tool_action": tool_name, "tool_call_id": tool_call_id, "status": "tool_call", - "arguments": args_dict if isinstance(args_dict, dict) else {}, - "message": f"Using {tool_name}", + "arguments": args, + "activity": activity, + "message": activity, }) _emit_event({ "kind": "tool_result", @@ -250,7 +291,8 @@ def _on_tool_progress(tool_name: str, args_preview: str, args_dict=None): "tool_action": tool_name, "tool_call_id": tool_call_id, "status": "tool_complete", - "message": f"{tool_name} in progress", + "activity": activity, + "message": activity, }) except Exception: pass # never let event emission break the agent run