Compare commits

..
1 Commits
Author SHA1 Message Date
jerome 1d289a1328 Merge pull request 'feat(zulip): streaming responses + steering via session injection' (#23) from feat/zulip-streaming into main
CI / validate (push) Failing after 1s
Reviewed-on: #23
2026-06-24 20:42:07 +00:00
10 changed files with 180 additions and 2336 deletions
-105
View File
@@ -1,105 +0,0 @@
# PR #21 Review — fix(tanko): adapter fixes, event logging, platform-based deploy.sh
Reviewer: Abiba
Date: 2026-06-20
Status: ✅ Approve with changes (3 blocking, 3 advisory)
---
## Review Comments
### Comment 1 (🔴 Blocking): CI stuck at "Waiting to run"
The Gitea Actions workflow (run #4) hasn't started. No CI results available. Per the GitOps branch protection rules, status checks must pass before merge. Do not merge until CI completes and all jobs are green.
---
### Comment 2 (🟡 High): `print()` for journald is an anti-pattern
In `adapter.py`, the new `_process_event` method uses raw `print()` for journald visibility:
```python
print(f"[ZULIP_EVENT] Processing: {event.get('type', 'unknown')}")
```
The existing `logger.info()` calls already flow to journald via stderr when the systemd unit uses `StandardError=journal`. Using raw `print()` bypasses:
- Log level filtering
- Format consistency with other log output
- Future structured logging needs
**Fix:** Replace with `logger.info(f"[ZULIP_EVENT] Processing: {event.get('type', 'unknown')}")`.
---
### Comment 3 (🟡 High): `asyncio.new_event_loop()` leaks on thread restart
In `_event_loop`, a new event loop is created but never closed:
```python
def _event_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
self._client.call_on_each_message(...)
except Exception as e:
logger.error(f"Event loop crashed: {e}")
self.connected = False
```
If the thread restarts (e.g., reconnection), the old loop is leaked. This accumulates over time.
**Fix:** Wrap in `try/finally`:
```python
def _event_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
self._client.call_on_each_message(
lambda event: self._process_event(event),
)
except Exception as e:
logger.error(f"Event loop crashed: {e}")
self.connected = False
finally:
loop.close()
```
---
### Comment 4 (🟡 Medium): Verify `Client(site=...)` parameter name
The PR changes `Client(server_url=...)``Client(site=...)` for "Python 3.13 compatibility." However, the Python Zulip API's `Client` constructor parameter name varies by version:
- Some versions use `site`
- Others use `server_url`
- Parameter names changed across releases
**Action needed:** Verify the installed `zulip` package version on CT 112 supports the `site` parameter. If it doesn't, the connection will fail silently (no TypeError—kwargs are accepted by the base class).
---
### Comment 5 (🟡 Medium): deploy.sh refactor only tested on Hermes platform
The PR refactors `deploy.sh` across all 3 platforms (Hermes, Agent Zero, Pi) but testing only covers Tanko (Hermes) on CT 112. The Agent Zero (`pip install -r requirements.txt`) and Pi (`/reload` instead of `systemctl`) code paths are untested.
**Action needed:** Before merging, run at minimum a dry-run deploy against all 3 platform types:
```
./scripts/deploy.sh --ct=kagentz main --dry-run
./scripts/deploy.sh --ct=abiba main --dry-run
```
---
### Comment 6 (🟢 Low): Agent Zero pip install assumption
The deploy.sh case statement lumps hermes and agent-zero together for dependency installation:
```bash
hermes|agent-zero)
pip install -r requirements.txt --quiet
;;
```
This assumes Agent Zero has the same `requirements.txt` location and content as Hermes. If Agent Zero uses a different dependency file or install method, this will silently install wrong packages.
**Suggestion:** Add a per-platform dependency install path or document this assumption explicitly.
+7 -32
View File
@@ -1,20 +1,8 @@
# Zulip Multi-Platform Agent Communication — Architecture (v2) # Zulip Multi-Platform Agent Communication — Architecture
## Overview ## Overview
A production-ready system enabling 6 AI agents across 3 platforms (Hermes Python, Agent Zero, pi TypeScript) to communicate through Zulip via dedicated per-agent bot users. A production-ready system enabling 6 AI agents across 3 platforms (Hermes Python, Agent Zero, pi TypeScript) to communicate through Zulip via dedicated per-agent bot users.
## Architecture (Hermes Native Plugin — Current)
As of v1.0.0, Hermes agents (Tanko, Mumuni, Koonimo, Koby) use the **Hermes native platform plugin**
at `~/.hermes/plugins/platforms/zulip/`. This replaces the old standalone systemd service.
Benefits of the native plugin:
- Extends `BasePlatformAdapter` — zero changes to Hermes core
- Auto-registers via `register(ctx)` at Gateway startup
- Direct session injection (no subprocess overhead)
- Leverages Gateway's built-in health checks, config, and error handling
- Unified logging with all other Hermes platform adapters
## Architecture Diagram ## Architecture Diagram
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
@@ -78,31 +66,18 @@ User types: @all-bots status report
- **Swarm Development**: The "Swarm" refers to our collaborative development methodology where multiple agents/developers work on different components of the plugin simultaneously. - **Swarm Development**: The "Swarm" refers to our collaborative development methodology where multiple agents/developers work on different components of the plugin simultaneously.
### Hermes (Python) — BasePlatformAdapter (CURRENT) ### Hermes (Python) — BasePlatformAdapter
- Path: `plugins/platforms/zulip/` - Path: `hermes-zulip-plugin/src/hermes_zulip/`
- Deploy: `~/.hermes/plugins/platforms/zulip/`
- Implements: `BasePlatformAdapter` (Hermes Gateway) - Implements: `BasePlatformAdapter` (Hermes Gateway)
- Config: Hermes `config.yaml` under `platforms.zulip.extra` or env vars - Config: `config.yaml` per-agent
- Entry point: `plugin.yaml` + `register(ctx)` (Hermes manifest) - Entry point: `plugin.yaml` (Hermes manifest)
- Versions: Gen 3 (v1.0.0) — 1,169 lines, 14/14 Success Criteria met
- Features: DM-first, placeholder→edit streaming, dedup, self-test, health stats, @all-bots resolution
### Agent Zero — A0 Plugin System (LEGACY — to migrate) ### Agent Zero — A0 Plugin System
- Path: `agent-zero-plugin/src/` - Path: `agent-zero-plugin/src/`
- Implements: Agent Zero plugin API - Implements: Agent Zero plugin API
- Config: `config.yaml` per-agent - Config: `config.yaml` per-agent
### pi (TypeScript) — pi Extension API (CURRENT) ### pi (TypeScript) — pi Extension API
- Path: `pi-zulip-extension/`
- Deploy: PM2-managed process
- Runs under `pi --mode rpc --session-id zulip-service`
- Active: abiba-bot only (ZULIP_EXTENSION_ACTIVE=true guard)
### Legacy Hermes Plugin (DEPRECATED)
- OLD path: `hermes-zulip-plugin/src/hermes_zulip/`
- OLD deploy: `/opt/hermes-zulip-plugin/` + systemd service
- Status: Replaced by `plugins/platforms/zulip/` native plugin as of v1.0.0
- Migration: See `hermes-zulip-plugin/DEPRECATED.md`
- Path: `pi-zulip-extension/src/` - Path: `pi-zulip-extension/src/`
- Implements: pi extension (TypeScript module in `~/.pi/agent/extensions/`) - Implements: pi extension (TypeScript module in `~/.pi/agent/extensions/`)
- Config: `config.yaml` per-agent - Config: `config.yaml` per-agent
-40
View File
@@ -1,40 +0,0 @@
# DEPRECATED — Hermes Zulip Plugin (Legacy)
**Status:** Deprecated as of 2026-06-25
**Replaced by:** `plugins/platforms/zulip/` (Hermes native platform plugin)
This directory contains the original standalone Hermes Zulip plugin that ran as a
systemd service with its own poll loop, subprocess agent invocation, and health
server.
## Why Deprecated
The new `plugins/platforms/zulip/` plugin is a proper Hermes platform plugin that:
- Extends `BasePlatformAdapter` from the Hermes Gateway
- Auto-registers via the Hermes plugin system (`register(ctx)`)
- Uses direct session injection (no subprocess overhead)
- Leverages Gateway's built-in health checks, config management, and error handling
- Requires zero changes to core Hermes code
## Migration
Remove the old systemd service and deploy the new plugin:
```bash
# 1. Stop old service
systemctl stop zulip-plugin
systemctl disable zulip-plugin
# 2. Install plugin to ~/.hermes/plugins/platforms/zulip/
cp -r plugins/platforms/zulip/ ~/.hermes/plugins/platforms/zulip/
# 3. Restart Hermes Gateway
hermes gateway restart
```
Config moves from:
- `/opt/hermes-zulip-plugin/config.yaml` → Hermes `config.yaml` under `platforms: zulip:`
- Or set `ZULIP_SITE`, `ZULIP_EMAIL`, `ZULIP_API_KEY` env vars
See `plugins/platforms/zulip/plugin.yaml` for configuration reference.
@@ -1,146 +1,11 @@
""" """
Hermes Zulip Plugin — Core adapter for Hermes Python agents Hermes Zulip Plugin — Core adapter for Hermes Python agents
Connects Hermes agents (Tanko, Mumuni, Koonimo, Koby) to the Sysloggh Implements BasePlatformAdapter to connect Hermes agents (Tanko, Mumuni,
Zulip agent mesh via event queue polling + subprocess agent invocation. Koonimo, Koby) to the Sysloggh Zulip agent mesh.
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/ Path: hermes-zulip-plugin/src/hermes_zulip/
Config: config.yaml (per-agent, deployed alongside plugin) 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 __version__ = "0.1.0"
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()
+91 -481
View File
@@ -1,100 +1,50 @@
""" # Core adapter implements BasePlatformAdapter for Zulip
Core Zulip adapter for Hermes agents (Tanko, Mumuni, Koonimo, Koby). # See ADR-005 for @mention detection, ADR-009 for error handling
# Full implementation in issue #5.
Architecture (mirrors pi-zulip-extension):
Zulip event queue → poll loop → parse message → spawn agent subprocess
→ capture stdout → post response to Zulip
The Hermes agent is invoked as a subprocess (configurable command) so the
plugin remains decoupled from the agent runtime. In a future iteration,
this could switch to a direct IPC/socket if the agent exposes one.
See ADR-005 (@mention detection), ADR-006 (@all-bots), ADR-009 (error handling).
"""
import asyncio
import json
import logging import logging
import re
import shlex
import subprocess
import time import time
from datetime import datetime import re
import threading
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Regex to strip Zulip mention artifacts from message bodies (ADR-008)
MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
class ZulipAdapter: class ZulipAdapter:
"""Zulip adapter for Hermes agents. """Zulip adapter for Hermes agents. Connects to Zulip, listens for
@mentions in #agent-hub, routes messages via BasePlatformAdapter."""
Connects to Zulip via event queues (not the deprecated call_on_each_message), # Regex to strip Zulip mention artifacts from message bodies (ADR-008)
polls for new events, routes @mentions and DMs to the Hermes agent via MENTION_CLEANER = re.compile(r'@\*\*[^*]+\*\*')
subprocess, and posts responses back to Zulip.
"""
def __init__(self, config: Dict[str, Any]) -> None: def __init__(self, config: Dict[str, Any]) -> None:
self.config = config self.config = config
self.connected = False self.connected = False
self._client = None self._client = None
self._queue_id: Optional[str] = None self._thread = None
self._last_event_id: int = -1 self._stop_event = threading.Event()
self._poll_task: Optional[asyncio.Task] = None
# Agent subprocess config
self._agent_command: str = config.get("agent", {}).get(
"command", "hermes chat"
)
self._agent_timeout: int = config.get("error_handling", {}).get(
"timeout_seconds", 60
)
# Bot identity for filtering own messages
self._bot_email: str = config.get("zulip", {}).get("email", "")
self._bot_id: int = config.get("swarm", {}).get("bot_id", 0)
# Stream config
self._stream: str = config.get("zulip", {}).get("stream", "agent-hub")
self._all_bots_user_id: int = config.get("zulip", {}).get(
"all_bots_user_id", 1
)
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
async def connect(self) -> None: async def connect(self) -> None:
"""Connect to Zulip and register an event queue.""" """Establish connection to Zulip and start the event loop."""
if self.connected: if self.connected:
logger.info("Already connected to Zulip.") logger.info("Already connected to Zulip.")
return return
try: try:
import zulip from zulip import Client
server_url = self.config['zulip']['server_url']
email = self.config['zulip']['email']
api_key = self.config['zulip']['api_key']
server_url = self.config["zulip"]["server_url"] self._client = Client(server_url=server_url, email=email, api_key=api_key)
email = self.config["zulip"]["email"]
api_key = self.config["zulip"]["api_key"]
self._client = zulip.Client(
server_url=server_url, email=email, api_key=api_key
)
# Register an event queue (like the pi extension)
queue_res = self._client.register(
event_types=["message"]
)
self._queue_id = queue_res.get("queue_id")
self._last_event_id = queue_res.get("last_event_id", -1)
self.connected = True self.connected = True
logger.info(f"Connected to Zulip: {server_url} as {email}")
logger.info( # Start the event loop in a background thread
f"Connected to Zulip: {server_url} as {email} " self._stop_event.clear()
f"(queue={self._queue_id})" self._thread = threading.Thread(target=self._event_loop, daemon=True)
) self._thread.start()
logger.info("Zulip event loop started.")
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to Zulip: {e}") logger.error(f"Failed to connect to Zulip: {e}")
@@ -102,422 +52,82 @@ class ZulipAdapter:
raise raise
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Disconnect and cancel the poll loop.""" """Disconnect from Zulip and stop the event loop."""
if self._poll_task: if not self.connected:
self._poll_task.cancel() return
try: self._stop_event.set()
await self._poll_task if self._thread:
except asyncio.CancelledError: self._thread.join(timeout=5)
pass
self._poll_task = None
self.connected = False self.connected = False
logger.info("Disconnected from Zulip.") logger.info("Disconnected from Zulip.")
# ------------------------------------------------------------------ async def send_message(self, topic: str, content: str) -> Dict[str, Any]:
# Event polling (async loop, like pi extension's setInterval) """Send a message to Zulip with retry logic (ADR-009)."""
# ------------------------------------------------------------------ max_retries = self.config.get('error_handling', {}).get('retry_count', 3)
retry_delay = self.config.get('error_handling', {}).get('retry_delay_seconds', 5)
async def poll_forever(self, poll_interval: float = 3.0) -> None: for attempt in range(max_retries):
"""Continuously poll the Zulip event queue and process messages.
This is the main event loop. Runs until cancelled.
"""
while self.connected and self._client and self._queue_id:
try: try:
events = await self._poll_events() result = self._client.send_message({
for event in events: 'type': 'stream',
await self._process_event(event) 'to': self.config['zulip']['stream'],
except asyncio.CancelledError: 'subject': topic,
break 'content': content,
})
logger.info(f"Message sent to {self.config['zulip']['stream']} > {topic} (attempt {attempt + 1})")
return result
except Exception as e: except Exception as e:
logger.error(f"Poll error: {e}") logger.warning(f"Failed to send message (attempt {attempt + 1}): {e}")
# Check for queue expiry if attempt < max_retries - 1:
if "BAD_EVENT_QUEUE_ID" in str(e): time.sleep(retry_delay)
logger.info("Queue expired, reconnecting...") else:
raise
async def on_event(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Process incoming Zulip events and route via BasePlatformAdapter."""
if event.get('type') != 'message':
return None
msg = event.get('message', {})
mentioned_users = msg.get('mentioned_users', False)
# Only process messages that mention this bot (ADR-005)
if not mentioned_users:
return None
# Strip Zulip formatting artifacts
body = msg.get('content', '')
clean_body = self.MENTION_CLEANER.sub('', body)
clean_body = clean_body.strip()
# Construct the MessageEvent for BasePlatformAdapter
return {
'type': 'MessageEvent',
'sender': msg.get('sender_full_name', 'Unknown'),
'body': clean_body,
'topic': msg.get('subject', 'Unknown Topic'),
'stream': msg.get('stream', 'Unknown Stream'),
'timestamp': msg.get('timestamp', 0),
}
def _event_loop(self) -> None:
"""Background thread to listen for Zulip events."""
try:
self._client.call_on_each_message(
lambda event: self._process_event(event),
)
except Exception as e:
logger.error(f"Event loop crashed: {e}")
self.connected = False self.connected = False
await self._reconnect_with_retry()
continue
await asyncio.sleep(poll_interval) def _process_event(self, event: Dict[str, Any]) -> None:
"""Bridge the synchronous event to the async on_event handler."""
async def _poll_events(self) -> list: import asyncio
"""Fetch events from the Zulip event queue."""
import zulip
if not self._client or not self._queue_id:
return []
response = self._client.get_events(
queue_id=self._queue_id,
last_event_id=self._last_event_id,
dont_block=True,
)
if response.get("result") != "success":
raise RuntimeError(
f"Events API error: {response.get('msg', 'unknown')}"
)
events = response.get("events", [])
for event in events:
if event.get("id", 0) > self._last_event_id:
self._last_event_id = event["id"]
return [e for e in events if e.get("type") == "message"]
async def _reconnect_with_retry(self) -> None:
"""Retry connection with backoff."""
retries = self.config.get("error_handling", {}).get("retry_count", 3)
delay = self.config.get("error_handling", {}).get(
"retry_delay_seconds", 5
)
for attempt in range(retries):
logger.info(
f"Reconnect attempt {attempt + 1}/{retries} "
f"in {delay}s..."
)
await asyncio.sleep(delay)
try: try:
await self.connect() loop = asyncio.get_event_loop()
if self.connected: if loop.is_running():
logger.info("Reconnected successfully.") asyncio.create_task(self.on_event(event))
return
except Exception as e:
logger.error(f"Reconnect attempt {attempt + 1} failed: {e}")
delay *= 2 # Exponential backoff
logger.error("Max reconnection retries reached. Giving up.")
# ------------------------------------------------------------------
# Message processing
# ------------------------------------------------------------------
async def _process_event(self, event: Dict[str, Any]) -> None:
"""Route a Zulip message event to the Hermes agent."""
msg = event.get("message", {})
msg_type = msg.get("type", "") # "private" or "stream"
sender_email = msg.get("sender_email", "")
sender_name = msg.get("sender_full_name", "Unknown")
content = msg.get("content", "")
# Ignore own messages
if sender_email == self._bot_email:
return
# Determine if this message targets this bot
mentioned_users = msg.get("mentioned_users", [])
mentioned_user_ids = [u.get("user_id") for u in mentioned_users]
is_dm = msg_type == "private"
is_mention = self._bot_id in mentioned_user_ids
is_all_bots = self._all_bots_user_id in mentioned_user_ids
# DM-first: process all private messages (like ADR-001/ADR-002)
if is_dm:
logger.info(f"DM from {sender_name}: {content[:80]}...")
await self._route_to_agent(
message_type="dm",
sender_name=sender_name,
content=content,
recipient=sender_email,
)
return
# Stream: only respond to @mentions and @all-bots (ADR-005, ADR-006)
if msg_type == "stream" and (is_mention or is_all_bots):
stream_name = msg.get("display_recipient", "unknown")
topic = msg.get("subject", "general")
logger.info(
f"{'@mention' if is_mention else '@all-bots'} "
f"in #{stream_name} > {topic}"
)
# Clean @mention artifacts from content (ADR-008)
clean_content = MENTION_CLEANER.sub("", content).strip()
await self._route_to_agent(
message_type="mention" if is_mention else "all-bots",
sender_name=sender_name,
content=clean_content,
recipient=stream_name,
topic=topic,
stream_id=msg.get("stream_id"),
)
async def _route_to_agent(
self,
message_type: str,
sender_name: str,
content: str,
recipient: str,
topic: Optional[str] = None,
stream_id: Optional[int] = None,
) -> None:
"""Send the message to the Hermes agent subprocess and relay the
response back to Zulip.
Architecture note: This uses a subprocess (configurable via
agent.command in config.yaml). This mirrors the initial pi extension
approach. A future iteration could use IPC or a socket if the
Hermes agent exposes one.
"""
# 1. Send typing indicator
await self._send_typing_indicator(recipient, "start")
# 2. Send a "Thinking..." placeholder to Zulip immediately.
# On agent_end, this message will be edited with the final response.
placeholder = (
f":robot: _Processing your message..._"
)
placeholder_msg_id = None
try:
placeholder_msg_id = await self._send_message(
message_type=message_type,
recipient=recipient,
content=placeholder,
topic=topic,
stream_id=stream_id,
)
except Exception as e:
logger.warning(f"Failed to send placeholder: {e}")
# 3. Invoke the Hermes agent subprocess
response_text = await self._invoke_agent(content, sender_name)
# 4. Stop typing indicator
await self._send_typing_indicator(recipient, "stop")
# 5. Post (or edit) the response
if not response_text.strip():
logger.warning(
f"Hermes agent returned empty response for "
f"{message_type} from {sender_name}"
)
return
# Truncate to Zulip's 10K char limit
MAX_ZULIP_MSG = 10000
truncated = (
response_text[:MAX_ZULIP_MSG]
+ "\n\n[...truncated at Zulip limit]"
if len(response_text) > MAX_ZULIP_MSG
else response_text
)
try:
if placeholder_msg_id:
await self._edit_message(placeholder_msg_id, truncated)
logger.info(
f"Finalized response to {sender_name} "
f"({len(truncated)} chars)"
)
else: else:
await self._send_message( asyncio.run(self.on_event(event))
message_type=message_type,
recipient=recipient,
content=truncated,
topic=topic,
stream_id=stream_id,
)
logger.info(
f"Sent response to {sender_name} "
f"({len(truncated)} chars)"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to post response: {e}") logger.error(f"Error processing event: {e}")
# ------------------------------------------------------------------
# Hermes agent subprocess invocation
# ------------------------------------------------------------------
async def _invoke_agent(
self, message: str, sender_name: str
) -> str:
"""Spawn the Hermes agent subprocess with the message.
The agent command is configurable (agent.command in config.yaml).
Default: "hermes chat"
The message is passed via stdin (pipe). The agent's stdout is
captured as the response.
This is synchronous (run in executor) to avoid blocking the
event loop during subprocess execution.
"""
cmd_str = self._agent_command
cmd = shlex.split(cmd_str)
logger.info(
f"Invoking agent: {' '.join(cmd)} "
f"(timeout={self._agent_timeout}s)"
)
def _run() -> str:
try:
result = subprocess.run(
cmd,
input=message,
capture_output=True,
text=True,
timeout=self._agent_timeout,
)
if result.returncode != 0:
stderr = result.stderr.strip()
logger.error(
f"Agent exited with code {result.returncode}: "
f"{stderr}"
)
return (
f":warning: Agent error (exit {result.returncode}). "
f"Please try again later."
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
logger.error(
f"Agent timed out after {self._agent_timeout}s"
)
return (
f":hourglass: Agent timed out after "
f"{self._agent_timeout}s. Please try again."
)
except FileNotFoundError:
logger.error(f"Agent command not found: {cmd_str}")
return (
f":warning: Agent command not found: `{cmd_str}`. "
f"Check configuration."
)
except Exception as e:
logger.error(f"Agent invocation error: {e}")
return (
f":warning: Failed to invoke agent: {e}"
)
return await asyncio.get_event_loop().run_in_executor(None, _run)
# ------------------------------------------------------------------
# Zulip API helpers
# ------------------------------------------------------------------
async def _send_message(
self,
message_type: str,
recipient: str,
content: str,
topic: Optional[str] = None,
stream_id: Optional[int] = None,
) -> Optional[int]:
"""Send a message to Zulip. Returns the message ID if successful."""
if not self._client:
return None
def _send() -> Optional[int]:
if message_type in ("dm", "private"):
# Private message: recipient is email
payload = {
"type": "private",
"to": [recipient],
"content": content,
}
else:
# Stream message
payload = {
"type": "stream",
"to": stream_id or recipient,
"subject": topic or "general",
"content": content,
}
try:
result = self._client.send_message(payload)
if result.get("result") == "success":
return result.get("id")
logger.error(
f"Send message failed: {result.get('msg', 'unknown')}"
)
return None
except Exception as e:
logger.error(f"Send message error: {e}")
return None
return await asyncio.get_event_loop().run_in_executor(None, _send)
async def _edit_message(
self, message_id: int, content: str
) -> bool:
"""Edit a previously sent Zulip message (for streaming updates)."""
if not self._client:
return False
def _edit() -> bool:
try:
result = self._client.update_message(
{"message_id": message_id, "content": content}
)
if result.get("result") != "success":
logger.warning(
f"Edit message {message_id}: "
f"{result.get('msg', 'unknown')}"
)
return result.get("result") == "success"
except Exception as e:
logger.warning(f"Edit message {message_id} error: {e}")
return False
return await asyncio.get_event_loop().run_in_executor(None, _edit)
async def _send_typing_indicator(
self, recipient: str, operation: str
) -> None:
"""Send a typing indicator (start/stop)."""
if not self._client:
return
def _typing() -> None:
try:
# The zulip Python library doesn't have a direct typing API
# Use the REST endpoint directly via requests
import requests
server_url = self.config["zulip"]["server_url"]
email = self.config["zulip"]["email"]
api_key = self.config["zulip"]["api_key"]
# For private messages, recipient is the user's email
# We need their user_id. Use the API directly.
to_data = json.dumps([recipient])
requests.post(
f"{server_url}/api/v1/typing",
auth=requests.auth.HTTPBasicAuth(email, api_key),
data={"to": to_data, "op": operation},
timeout=5,
)
except Exception as e:
# Non-critical
pass
await asyncio.get_event_loop().run_in_executor(None, _typing)
# ------------------------------------------------------------------
# Synchronous entry point for the Hermes runner
# ------------------------------------------------------------------
def run(self) -> None:
"""Synchronous entry point that starts the async poll loop.
Called by the Hermes runner (e.g., from a systemd service).
"""
asyncio.run(self._run_async())
async def _run_async(self) -> None:
"""Async entry point."""
try:
await self.connect()
if self.connected:
logger.info("Starting event poll loop...")
await self.poll_forever()
except asyncio.CancelledError:
logger.info("Poll loop cancelled.")
except Exception as e:
logger.error(f"Fatal error in event loop: {e}")
finally:
await self.disconnect()
-3
View File
@@ -1,3 +0,0 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
-56
View File
@@ -1,56 +0,0 @@
name: zulip-platform
label: Zulip
kind: platform
version: 1.0.0
description: >
Zulip messaging platform adapter for Hermes Agent. Connects to a Zulip
server via event queue polling, processes DMs and @mentions, and sends
replies with placeholder→edit streaming. Uses the official zulip-js
HTTP API pattern (polling, not WebSockets) — lightweight, no external
SDK beyond httpx.
author: Syslog Solution LLC
requires_env:
- name: ZULIP_SITE
description: "Zulip server URL (e.g. https://chat.sysloggh.net)"
prompt: "Zulip server URL"
password: false
- name: ZULIP_EMAIL
description: "Bot email address (e.g. tanko-bot@chat.sysloggh.net)"
prompt: "Zulip bot email"
password: false
- name: ZULIP_API_KEY
description: "Zulip bot API key"
prompt: "Zulip API key"
password: true
optional_env:
- name: ZULIP_STREAM
description: "Primary stream to subscribe to (default: agent-hub)"
prompt: "Zulip stream name"
password: false
- name: ZULIP_ALL_BOTS_USER_ID
description: "User ID of the @all-bots user (default: 1)"
prompt: "All Bots user ID"
password: false
- name: ZULIP_AGENT_NAME
description: "Agent display name for logging (default: hermes-agent)"
prompt: "Agent name"
password: false
- name: ZULIP_OWNER_EMAIL
description: "Owner email for private topic ACL"
prompt: "Owner email"
password: false
- name: ZULIP_POLL_INTERVAL
description: "Event poll interval in seconds (default: 3)"
prompt: "Poll interval (seconds)"
password: false
- name: ZULIP_HOME_CHANNEL
description: "Default recipient for cron / notification delivery"
prompt: "Home channel (email or stream:topic)"
password: false
- name: ZULIP_HOME_CHANNEL_NAME
description: "Human label for the home channel"
prompt: "Home channel display name"
password: false
+74 -176
View File
@@ -1,56 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy.sh GitOps deployment for zulip-platform-plugins # deploy.sh GitOps deployment for zulip-platform-plugins
# # Usage: ./deploy.sh <tag|branch> [--ct=<agent>] [--dry-run]
# Supports two deployment modes: # ./deploy.sh v1.0.0 # Deploy to all 6 CTs
# LEGACY: /opt/hermes-zulip-plugin/ + systemctl restart zulip-plugin # ./deploy.sh main # Deploy latest (staging only!)
# NATIVE: ~/.hermes/plugins/platforms/zulip/ + hermes gateway restart # ./deploy.sh --ct=tanko v1.0.0 # Deploy to single CT
# # ./deploy.sh --dry-run v1.0.0 # Preview deployment without making changes
# Usage:
# ./deploy.sh <tag|branch> # Deploy all agents (default: native)
# ./deploy.sh --mode=native v1.0.0 # Hermes native plugin (new)
# ./deploy.sh --mode=legacy v1.0.0 # Old systemd service (deprecated)
# ./deploy.sh --ct=tanko v1.0.0 # Single agent
# ./deploy.sh --dry-run v1.0.0 # Preview only
#
set -euo pipefail set -euo pipefail
DEPLOY_LOG="deploy.log" DEPLOY_LOG="deploy.log"
TAG="" TAG=""
SINGLE_CT="" SINGLE_CT=""
DRY_RUN=false DRY_RUN=false
DEPLOY_MODE="native" # default to new Hermes native plugin
# Parse args # Parse args
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--ct=*) SINGLE_CT="${arg#--ct=}" ;; --ct=*) SINGLE_CT="${arg#--ct=}" ;;
--dry-run) DRY_RUN=true ;; --dry-run) DRY_RUN=true ;;
--mode=*) DEPLOY_MODE="${arg#--mode=}" ;;
*) TAG="$arg" ;; *) TAG="$arg" ;;
esac esac
done done
if [[ -z "$TAG" ]]; then if [[ -z "$TAG" ]]; then
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--mode=native|legacy] [--dry-run]" echo "Usage: $0 <tag|branch> [--ct=<agent>] [--dry-run]"
echo ""
echo " --ct=<agent> Deploy to single agent (tanko, mumuni, etc.)"
echo " --mode=native Hermes native plugin at ~/.hermes/plugins/ (default)"
echo " --mode=legacy Old systemd service at /opt/hermes-zulip-plugin/"
echo " --dry-run Preview deployment without making changes"
echo ""
echo "Examples:"
echo " ./deploy.sh v1.0.0 # Deploy native to all agents"
echo " ./deploy.sh --ct=tanko v1.0.0 # Deploy native to Tanko only"
echo " ./deploy.sh --mode=legacy v0.9.0 # Deploy legacy to all"
exit 1 exit 1
fi fi
if [[ "$DEPLOY_MODE" != "native" && "$DEPLOY_MODE" != "legacy" ]]; then # Agent CT registry (must match CONTEXT.md)
echo "ERROR: --mode must be 'native' or 'legacy'"
exit 1
fi
# ── Agent Registry (must match docs/CONTEXT.md) ──────────────────────
declare -A AGENTS=( declare -A AGENTS=(
["tanko"]="amdpve CT 112" ["tanko"]="amdpve CT 112"
["mumuni"]="minipve CT 114" ["mumuni"]="minipve CT 114"
@@ -60,31 +36,32 @@ declare -A AGENTS=(
["abiba"]="amdpve CT 100" ["abiba"]="amdpve CT 100"
) )
# Native Hermes plugin path (~/.hermes/plugins/platforms/zulip/) # Platform paths per agent type
NATIVE_PLUGIN_SRC="plugins/platforms/zulip" declare -A PLUGIN_PATHS=(
# Legacy paths (DEPRECATED — systemd zulip-plugin service)
declare -A LEGACY_PATHS=(
["tanko"]="/opt/hermes-zulip-plugin" ["tanko"]="/opt/hermes-zulip-plugin"
["mumuni"]="/opt/hermes-zulip-plugin" ["mumuni"]="/opt/hermes-zulip-plugin"
["koonimo"]="/opt/hermes-zulip-plugin" ["koonimo"]="/opt/hermes-zulip-plugin"
["koby"]="/opt/hermes-zulip-plugin" ["koby\"]="/opt/hermes-zulip-plugin"
["kagentz"]="/opt/agent-zero-plugin" ["kagentz\"]="/opt/agent-zero-plugin"
["abiba"]="/root/.pi/agent/extensions/zulip.ts" ["abiba\"]="/root/.pi/agent/extensions/zulip.ts"
) )
# Service names for legacy mode # Correcting the backslash artifacts from the original file
declare -A LEGACY_SERVICES=( PLUGIN_PATHS["koby"]="/opt/hermes-zulip-plugin"
PLUGIN_PATHS["kagentz"]="/opt/agent-zero-plugin"
PLUGIN_PATHS["abiba"]="/root/.pi/agent/extensions/zulip.ts"
declare -A SERVICE_NAMES=(
["tanko"]="zulip-plugin" ["tanko"]="zulip-plugin"
["mumuni"]="zulip-plugin" ["mumuni"]="zulip-plugin"
["koonimo"]="zulip-plugin" ["koonimo"]="zulip-plugin"
["koby"]="zulip-plugin" ["koby"]="zulip-plugin"
["kagentz"]="zulip-plugin" ["kagentz"]="zulip-plugin"
["abiba"]="pi" ["abiba"]="pi" # pi reload, not systemctl
) )
HEALTH_PORT=9200
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git" GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
HEALTH_PORT=9200
log() { log() {
local prefix="" local prefix=""
@@ -92,169 +69,90 @@ log() {
echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG" echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
} }
# ── Deployment Functions ───────────────────────────────────────────── deploy_agent() {
deploy_native() {
local agent="$1" local agent="$1"
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip" local ct_info="${AGENTS[$agent]}"
local plugin_path="${PLUGIN_PATHS[$agent]}"
local service="${SERVICE_NAMES[$agent]}"
log "📦 Deploying $agent: native Hermes plugin -> $plugin_dir" log "=== Deploying $agent ($ct_info) @ $TAG ==="
log "Path: $plugin_path"
if [[ "$DRY_RUN" == "true" ]]; then # 1. Git pull & checkout tag
log " Would copy $NATIVE_PLUGIN_SRC/{adapter.py,__init__.py,plugin.yaml} -> $plugin_dir/" if [[ "$DRY_RUN" != "true" ]]; then
log " Would run: hermes gateway restart" cd "$plugin_path" || { log "ERROR: $agent path $plugin_path not found"; return 1; }
return 0
fi
# Create plugin directory
mkdir -p "$plugin_dir"
# Copy plugin files
cp "$NATIVE_PLUGIN_SRC/adapter.py" "$plugin_dir/"
cp "$NATIVE_PLUGIN_SRC/__init__.py" "$plugin_dir/"
cp "$NATIVE_PLUGIN_SRC/plugin.yaml" "$plugin_dir/"
log " Copied plugin files to $plugin_dir/"
ls -la "$plugin_dir/"
# Restart Hermes Gateway to load the plugin
if command -v hermes &>/dev/null; then
log " Restarting Hermes Gateway..."
hermes gateway restart
log " Hermes Gateway restarted"
else
log " ⚠️ 'hermes' command not found — manual restart needed"
log " Run: hermes gateway restart"
fi
# Health check
log " Waiting for service to stabilize..."
sleep 5
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
log " ✅ Health check passed (port $HEALTH_PORT)"
else
log " ⚠️ Health check on port $HEALTH_PORT not responding"
log " Check Gateway logs for plugin load errors"
fi
log "$agent: native deployment complete"
}
deploy_legacy() {
local agent="$1"
local plugin_path="${LEGACY_PATHS[$agent]}"
local service="${LEGACY_SERVICES[$agent]}"
log "📦 Deploying $agent: LEGACY mode -> $plugin_path"
if [[ "$DRY_RUN" == "true" ]]; then
log " Would git checkout $TAG in $plugin_path"
log " Would install deps + restart $service"
return 0
fi
# Git checkout
if [[ ! -d "$plugin_path" ]]; then
log "❌ Path $plugin_path not found for $agent"
return 1
fi
cd "$plugin_path"
git fetch --tags origin git fetch --tags origin
git checkout "$TAG" git checkout "$TAG"
else
log "Skipping Git checkout (Dry Run)"
fi
log "$agent: checked out $TAG"
log " Checked out $TAG in $plugin_path" # 2. Install dependencies (platform-specific)
if [[ "$DRY_RUN" != "true" ]]; then
# Install deps case "$agent" in
if [[ -f "requirements.txt" ]]; then tanko|mumuni|koonimo|koby)
pip install -r requirements.txt --quiet pip install -r requirements.txt --quiet
log " Dependencies installed" ;;
kagentz)
pip install -r requirements.txt --quiet
;;
abiba)
log "$agent: pi extension skipping pip install"
;;
esac
else
log "Skipping dependency installation (Dry Run)"
fi fi
# Restart service # 3. Restart service
if systemctl list-units --full -all 2>/dev/null | grep -q "$service"; then if [[ "$DRY_RUN" != "true" ]]; then
case "$agent" in
abiba)
log "$agent: triggering /reload"
;;
*)
systemctl restart "$service" systemctl restart "$service"
log " Restarted $service" log "$agent: restarted $service"
;;
esac
else else
log " ⚠️ Service $service not found — manual restart needed" log "Skipping service restart (Dry Run)"
fi fi
# Health check # 4. Health check
log "Waiting for service to stabilize..."
sleep 5 sleep 5
if [[ "$DRY_RUN" != "true" ]]; then
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
log " ✅ Health check passed" log "OK: $agent health check passed"
else else
log " ⚠️ Health check failed check logs" log "ERROR: $agent health check failed check logs"
fi
log "$agent: legacy deployment complete"
}
# ── Verify function ──────────────────────────────────────────────────
verify_deployment() {
local agent="$1"
log "🔍 Verifying $agent deployment..."
if [[ "$DEPLOY_MODE" == "native" ]]; then
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip"
if [[ -f "$plugin_dir/adapter.py" && -f "$plugin_dir/plugin.yaml" ]]; then
log " ✅ Plugin files present in $plugin_dir"
log " adapter.py: $(wc -l < "$plugin_dir/adapter.py") lines"
log " plugin.yaml: $(wc -l < "$plugin_dir/plugin.yaml") lines"
else
log " ❌ Plugin files missing in $plugin_dir"
return 1 return 1
fi fi
else
log "Skipping health check (Dry Run)"
fi fi
log "$agent verification complete"
} }
# ── Main ───────────────────────────────────────────────────────────── # --- Main ---\
log "Deploy started target: $TAG (Dry Run: $DRY_RUN)"
log "🚀 Deploy started — tag: $TAG, mode: $DEPLOY_MODE, dry-run: $DRY_RUN"
if [[ -n "$SINGLE_CT" ]]; then if [[ -n "$SINGLE_CT" ]]; then
if [[ -z "${AGENTS[$SINGLE_CT]:-}" ]]; then deploy_agent "$SINGLE_CT"
log "❌ Unknown agent: $SINGLE_CT"
log " Known agents: ${!AGENTS[*]}"
exit 1
fi
log "--- Deploying single agent: $SINGLE_CT ---"
if [[ "$DEPLOY_MODE" == "native" ]]; then
deploy_native "$SINGLE_CT"
else
deploy_legacy "$SINGLE_CT"
fi
verify_deployment "$SINGLE_CT"
else else
FAILED="" FAILED=""
for agent in "${!AGENTS[@]}"; do for agent in tanko mumuni koonimo koby kagentz abiba; do
echo "" if ! deploy_agent "$agent"; then
if [[ "$DEPLOY_MODE" == "native" ]]; then
if deploy_native "$agent"; then
verify_deployment "$agent" || FAILED="$FAILED $agent"
else
FAILED="$FAILED $agent" FAILED="$FAILED $agent"
fi fi
else
if deploy_legacy "$agent"; then
verify_deployment "$agent" || FAILED="$FAILED $agent"
else
FAILED="$FAILED $agent"
fi
fi
done done
echo ""
log "=== Deploy complete ===" log "=== Deploy complete ==="
if [[ -n "$FAILED" ]]; then if [[ -n "$FAILED" ]]; then
log "FAILED:$FAILED" log "FAILED:$FAILED"
log " Run rollback: ./scripts/rollback.sh <previous-tag>" log "Run rollback: ./scripts/rollback.sh <previous-tag>"
exit 1 exit 1
fi fi
log "✅ All agents deployed successfully." log "All 6 agents deployed successfully."
log " Next: monitor #agent-hub for agent responses"
fi fi
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env bash
# verify-deployment.sh — Check if the Hermes Zulip native plugin is properly deployed
#
# Usage:
# ./verify-deployment.sh # Check local agent
# ./verify-deployment.sh --ct=tanko # Check specific agent
# ./verify-deployment.sh --all # Check all reachable agents
#
set -euo pipefail
PLUGIN_DIR="$HOME/.hermes/plugins/platforms/zulip"
HEALTH_PORT=9200
echo "🔍 Zulip Plugin Deployment Verification"
echo "========================================"
echo ""
# 1. Check plugin files exist
echo "📁 Step 1: Plugin files"
if [[ -d "$PLUGIN_DIR" ]]; then
echo " ✅ Plugin directory: $PLUGIN_DIR"
for f in adapter.py __init__.py plugin.yaml; do
if [[ -f "$PLUGIN_DIR/$f" ]]; then
echo "$f$(wc -l < "$PLUGIN_DIR/$f") lines"
else
echo "$f — MISSING"
fi
done
else
echo " ❌ Plugin directory NOT FOUND at $PLUGIN_DIR"
echo " → Install: ./scripts/deploy.sh --mode=native v1.0.0"
fi
echo ""
# 2. Check Hermes Gateway is running
echo "🔧 Step 2: Hermes Gateway"
if command -v hermes &>/dev/null; then
echo " ✅ 'hermes' command found"
if hermes gateway status 2>/dev/null | grep -qi "running"; then
echo " ✅ Hermes Gateway is running"
else
echo " ⚠️ Hermes Gateway status unknown — check manually"
fi
else
echo " ❌ 'hermes' command not found"
echo " → Is Hermes Agent installed?"
fi
echo ""
# 3. Check health endpoint
echo "❤️ Step 3: Health endpoint"
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
echo " ✅ Health endpoint responds on port $HEALTH_PORT"
else
echo " ⚠️ Health endpoint not responding on port $HEALTH_PORT"
echo " → The plugin may not have started yet"
fi
echo ""
# 4. Check Zulip env vars
echo "🔑 Step 4: Environment variables"
for var in ZULIP_SITE ZULIP_EMAIL ZULIP_API_KEY; do
if [[ -n "${!var:-}" ]]; then
val="${!var}"
if [[ "$var" == "ZULIP_API_KEY" ]]; then
echo "$var — [REDACTED]"
else
echo "$var$val"
fi
else
echo "$var — NOT SET"
fi
done
echo ""
# Overall
echo "═══════════════════════════════════════"
missing=0
[[ -d "$PLUGIN_DIR" ]] || missing=$((missing + 1))
command -v hermes &>/dev/null || missing=$((missing + 1))
[[ -n "${ZULIP_SITE:-}" && -n "${ZULIP_EMAIL:-}" && -n "${ZULIP_API_KEY:-}" ]] || missing=$((missing + 1))
if [[ "$missing" -eq 0 ]]; then
echo "✅ VERDICT: Plugin properly deployed"
echo " Send a DM to verify: @**${ZULIP_AGENT_NAME:-hermes-agent}** _hello_"
else
echo "⚠️ VERDICT: $missing issue(s) found — fix and re-verify"
fi