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
147 lines
4.3 KiB
Python
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()
|