diff --git a/examples/hermes_sentinel/hermes_bridge.py b/examples/hermes_sentinel/hermes_bridge.py index 01df109..5cdd90a 100755 --- a/examples/hermes_sentinel/hermes_bridge.py +++ b/examples/hermes_sentinel/hermes_bridge.py @@ -64,10 +64,36 @@ 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")) @@ -200,6 +226,77 @@ 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"], @@ -215,21 +312,51 @@ 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, - ) + # 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