Files
zulip-platform-plugins/hermes-zulip-plugin/src/hermes_zulip/__init__.py
T
Abiba (pi) 397efef776
CI / validate (pull_request) Failing after 1s
feat(hermes): full Zulip adapter with subprocess agent routing + streaming
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
2026-06-24 20:47:47 +00:00

147 lines
4.3 KiB
Python

"""
Hermes Zulip Plugin — Core adapter for Hermes Python agents
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
"""
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()