feat(hermes): full Zulip adapter with subprocess agent routing + streaming
CI / validate (pull_request) Failing after 1s

Complete rewrite of hermes-zulip-plugin following the same architecture
validated in pi-zulip-extension:

- Zulip event queue registration + async poll loop (replaces deprecated
  call_on_each_message threaded approach)
- DM-first processing (ADR-001/ADR-002): all private messages routed to agent
- @mention and @all-bots detection (ADR-005/ADR-006) with mention cleanup
- Subprocess agent invocation (configurable via agent.command) with stdin
  message passing and stdout response capture
- Placeholder + edit pattern: sends 'Thinking...' immediately, edits with
  final response (like pi extension streaming)
- Typing indicators (start/stop) via REST API
- Typing indicator support via REST API
- 10K Zulip char limit with graceful truncation
- Error handling: timeout, FileNotFoundError, agent crash → graceful Zulip msg
- Health endpoint (:9200) in background thread
- CLI entry point: python3 -m hermes_zulip --config config.yaml
This commit is contained in:
Abiba (pi)
2026-06-24 20:47:47 +00:00
parent 0a7b69f386
commit 397efef776
2 changed files with 612 additions and 87 deletions
@@ -1,11 +1,146 @@
"""
Hermes Zulip Plugin — Core adapter for Hermes Python agents
Implements BasePlatformAdapter to connect Hermes agents (Tanko, Mumuni,
Koonimo, Koby) to the Sysloggh Zulip agent mesh.
Connects Hermes agents (Tanko, Mumuni, Koonimo, Koby) to the Sysloggh
Zulip agent mesh via event queue polling + subprocess agent invocation.
Architecture mirrors pi-zulip-extension:
Zulip event queue → poll loop → parse message → spawn agent subprocess
→ capture stdout → post response to Zulip (with placeholder + edit)
Usage (systemd service):
python3 -m hermes_zulip --config /path/to/config.yaml
Path: hermes-zulip-plugin/src/hermes_zulip/
Config: config.yaml (per-agent, deployed alongside plugin)
@see ADR-001 DM-first, ADR-005 @mention detection
@see ADR-006 @all-bots, ADR-009 error handling
"""
__version__ = "0.1.0"
import argparse
import logging
import sys
from typing import Dict, Any
# Ensure the src directory is on the path for the adapter import
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from hermes_zulip.adapter import ZulipAdapter
__version__ = "0.2.0"
def load_config(config_path: str) -> Dict[str, Any]:
"""Load YAML config and inject env vars."""
import yaml
with open(config_path) as f:
cfg = yaml.safe_load(f)
# Override api_key from env if set
env_key = os.environ.get("ZULIP_API_KEY")
if env_key:
cfg.setdefault("zulip", {})["api_key"] = env_key
return cfg
def setup_logging(cfg: Dict[str, Any]) -> None:
"""Configure logging from config."""
level = (
cfg.get("monitoring", {}).get("log_level", "INFO").upper()
)
logging.basicConfig(
level=getattr(logging, level, logging.INFO),
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def start_health_server(cfg: Dict[str, Any], adapter: ZulipAdapter) -> None:
"""Start a minimal health HTTP endpoint in a background thread."""
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
port = cfg.get("monitoring", {}).get("health_port", 9200)
start_time = time.time()
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path in ("/", "/health"):
import json
body = json.dumps({
"status": "ok",
"agent": cfg.get("agent", {}).get("name", "unknown"),
"connected": adapter.connected,
"uptime_seconds": int(time.time() - start_time),
"timestamp": datetime.utcnow().isoformat(),
})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, fmt, *args):
pass # Suppress health check log spam
server = HTTPServer(("127.0.0.1", port), HealthHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
logging.getLogger(__name__).info(f"Health endpoint on :{port}")
def main() -> None:
"""CLI entry point for the Hermes Zulip plugin.
Usage:
python3 -m hermes_zulip --config /path/to/config.yaml
"""
import time
from datetime import datetime
parser = argparse.ArgumentParser(
description="Hermes Zulip Plugin — Connects Hermes agents to Zulip"
)
parser.add_argument(
"--config", "-c",
default="config.yaml",
help="Path to config.yaml (default: config.yaml)",
)
args = parser.parse_args()
# Load config
cfg = load_config(args.config)
setup_logging(cfg)
logger = logging.getLogger(__name__)
logger.info(
f"Starting Hermes Zulip Plugin v{__version__} "
f"for {cfg.get('agent', {}).get('name', 'unknown')}"
)
# Create adapter
adapter = ZulipAdapter(cfg)
# Start health endpoint (if enabled)
if cfg.get("monitoring", {}).get("health_endpoint_enabled", True):
start_health_server(cfg, adapter)
# Run the event loop (blocks until interrupted)
try:
adapter.run()
except KeyboardInterrupt:
logger.info("Shutting down...")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()