Compare commits
7
Commits
2ee8a4d818
..
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bb438de6e | ||
|
|
b1bef9024f | ||
|
|
dcf2de0052 | ||
|
|
715d54564f | ||
|
|
10e4e0374f | ||
|
|
0acf1068bf | ||
|
|
1c8f48fd77 |
+105
@@ -0,0 +1,105 @@
|
||||
# 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.
|
||||
+32
-7
@@ -1,8 +1,20 @@
|
||||
# Zulip Multi-Platform Agent Communication — Architecture
|
||||
# Zulip Multi-Platform Agent Communication — Architecture (v2)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
@@ -66,18 +78,31 @@ 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.
|
||||
|
||||
|
||||
### Hermes (Python) — BasePlatformAdapter
|
||||
- Path: `hermes-zulip-plugin/src/hermes_zulip/`
|
||||
### Hermes (Python) — BasePlatformAdapter (CURRENT)
|
||||
- Path: `plugins/platforms/zulip/`
|
||||
- Deploy: `~/.hermes/plugins/platforms/zulip/`
|
||||
- Implements: `BasePlatformAdapter` (Hermes Gateway)
|
||||
- Config: `config.yaml` per-agent
|
||||
- Entry point: `plugin.yaml` (Hermes manifest)
|
||||
- Config: Hermes `config.yaml` under `platforms.zulip.extra` or env vars
|
||||
- Entry point: `plugin.yaml` + `register(ctx)` (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
|
||||
### Agent Zero — A0 Plugin System (LEGACY — to migrate)
|
||||
- Path: `agent-zero-plugin/src/`
|
||||
- Implements: Agent Zero plugin API
|
||||
- Config: `config.yaml` per-agent
|
||||
|
||||
### pi (TypeScript) — pi Extension API
|
||||
### pi (TypeScript) — pi Extension API (CURRENT)
|
||||
- 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/`
|
||||
- Implements: pi extension (TypeScript module in `~/.pi/agent/extensions/`)
|
||||
- Config: `config.yaml` per-agent
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
"""
|
||||
Zulip platform adapter (Hermes plugin).
|
||||
Zulip platform adapter (Hermes plugin) — Gen 3.
|
||||
|
||||
Connects a Hermes agent to Zulip via event queue polling. DMs and @mentions
|
||||
are routed to the agent via the Hermes Gateway's handle_message() interface.
|
||||
Replies use a placeholder→edit streaming pattern for UX feedback.
|
||||
Replies use a placeholder->edit streaming pattern for UX feedback.
|
||||
|
||||
Gen 3 Improvements (2026-06-26):
|
||||
1. Malformed message resilience — try/except wraps each event, no crash on bad JSON
|
||||
2. Periodic @all-bots refresh — background task re-resolves user ID every hour
|
||||
3. Health stats callback — external consumers can register for periodic stats
|
||||
|
||||
Gen 2 Improvements (2026-06-26):
|
||||
1. Background dedup maintenance — O(1) per-message path, periodic cleanup task
|
||||
2. Self-test capability — selftest() method verifying DM/mention/queue health
|
||||
3. Health stats tracking — poll stats, reconnect counts, error rates
|
||||
4. Dynamic @all-bots resolution — falls back to hardcoded default
|
||||
|
||||
Architecture (mirrors pi-zulip-extension):
|
||||
Zulip event queue → poll loop → parse message → handle_message(MessageEvent)
|
||||
→ Gateway processes → send() / edit_message() posts response to Zulip
|
||||
Zulip event queue -> poll loop -> parse message -> handle_message(MessageEvent)
|
||||
-> Gateway processes -> send() / edit_message() posts response to Zulip
|
||||
|
||||
No external SDK required — only httpx (already a Hermes dependency).
|
||||
Ships as a Hermes platform plugin under plugins/platforms/zulip/.
|
||||
@@ -21,8 +32,9 @@ import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import httpx
|
||||
@@ -31,7 +43,7 @@ except ImportError:
|
||||
HTTPX_AVAILABLE = False
|
||||
httpx = None
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
@@ -48,6 +60,11 @@ DEFAULT_POLL_INTERVAL = 3.0
|
||||
MAX_ZULIP_MESSAGE = 10000
|
||||
ECHO_TAG_PREFIX = "hermes-zulip-"
|
||||
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||
DEDUP_WINDOW = 300 # 5 minutes
|
||||
DEDUP_MAX_SIZE = 1000
|
||||
DEDUP_CLEANUP_INTERVAL = 120 # Clean old entries every 2 minutes
|
||||
ALL_BOTS_REFRESH_INTERVAL = 3600 # Re-resolve @all-bots user ID every hour
|
||||
SELFTEST_TIMEOUT = 30 # seconds to wait for self-test response
|
||||
|
||||
# Regex to strip Zulip @mention markup
|
||||
MENTION_CLEANER = re.compile(r"@\*\*[^*]+\*\*")
|
||||
@@ -86,12 +103,23 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
|
||||
Connects to Zulip via event queues, polls for new events, converts
|
||||
DMs and @mentions to MessageEvent, and sends responses with streaming
|
||||
placeholder→edit UX.
|
||||
placeholder->edit UX.
|
||||
|
||||
Gen 3 adds:
|
||||
- Malformed message resilience (per-event try/except)
|
||||
- Periodic @all-bots refresh (hourly background task)
|
||||
- Health stats callback for external consumers
|
||||
|
||||
Gen 2 adds:
|
||||
- Background dedup maintenance task (O(1) per-message)
|
||||
- Self-test diagnostics (DM/mention/queue health)
|
||||
- Health stats for monitoring
|
||||
- Dynamic @all-bots resolution
|
||||
"""
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
platform_name = "zulip"
|
||||
super().__init__(config=config, platform=platform_name)
|
||||
platform = Platform("zulip")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
extra = config.extra or {}
|
||||
|
||||
@@ -111,9 +139,17 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
self._stream: str = (
|
||||
extra.get("stream") or os.getenv("ZULIP_STREAM", DEFAULT_STREAM)
|
||||
)
|
||||
self._all_bots_user_id: int = int(
|
||||
|
||||
# Self-test config — owner user ID for DM self-tests
|
||||
self._owner_user_id: Optional[int] = self._resolve_int_opt(
|
||||
extra.get("owner_user_id") or os.getenv("ZULIP_OWNER_USER_ID", "")
|
||||
)
|
||||
|
||||
# @all-bots user ID — dynamically resolved on connect, falls back to config/env/default
|
||||
self._all_bots_user_id: int = self._resolve_int(
|
||||
extra.get("all_bots_user_id")
|
||||
or os.getenv("ZULIP_ALL_BOTS_USER_ID", str(DEFAULT_ALL_BOTS_USER_ID))
|
||||
or os.getenv("ZULIP_ALL_BOTS_USER_ID", str(DEFAULT_ALL_BOTS_USER_ID)),
|
||||
DEFAULT_ALL_BOTS_USER_ID,
|
||||
)
|
||||
|
||||
# Polling
|
||||
@@ -122,19 +158,71 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
or os.getenv("ZULIP_POLL_INTERVAL", str(DEFAULT_POLL_INTERVAL))
|
||||
)
|
||||
|
||||
# State
|
||||
# --- State ---
|
||||
self._auth_header: str = _build_auth_header(self._email, self._api_key)
|
||||
self._queue_id: Optional[str] = None
|
||||
self._last_event_id: int = -1
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._dedup_cleanup_task: Optional[asyncio.Task] = None
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._seen_message_ids: Dict[str, float] = {}
|
||||
self._pending_replies: Dict[str, int] = {} # msg_id -> placeholder zulip_msg_id
|
||||
self._pending_replies: Dict[str, int] = {} # chat_id -> placeholder zulip_msg_id
|
||||
|
||||
# Derived identity for echo-loop prevention
|
||||
self._bot_user_id: Optional[int] = None
|
||||
self._bot_email: str = self._email
|
||||
|
||||
# --- Gen 2: Dedup (background-maintained) ---
|
||||
self._seen_message_ids: Dict[str, float] = {}
|
||||
|
||||
# --- Gen 2/3: Health stats ---
|
||||
self._health_stats: Dict[str, Any] = {
|
||||
"started_at": None,
|
||||
"poll_count": 0,
|
||||
"poll_errors": 0,
|
||||
"malformed_events": 0,
|
||||
"messages_received": 0,
|
||||
"dms_routed": 0,
|
||||
"mentions_routed": 0,
|
||||
"reconnects": 0,
|
||||
"send_count": 0,
|
||||
"send_errors": 0,
|
||||
"all_bots_refreshes": 0,
|
||||
"last_poll_at": None,
|
||||
"last_message_at": None,
|
||||
"last_error_at": None,
|
||||
"last_error_msg": None,
|
||||
}
|
||||
|
||||
# --- Gen 2: Self-test future for async response ---
|
||||
self._selftest_future: Optional[asyncio.Future] = None
|
||||
|
||||
# --- Gen 3: Periodic @all-bots refresh ---
|
||||
self._all_bots_refresh_task: Optional[asyncio.Task] = None
|
||||
|
||||
# --- Gen 3: Health stats callback ---
|
||||
self._health_callback = None
|
||||
self._health_callback_interval: int = 600 # 10 min
|
||||
|
||||
@staticmethod
|
||||
def _resolve_int(val: Any, default: int) -> int:
|
||||
"""Resolve an integer config value, falling back to default."""
|
||||
if val is None or val == "":
|
||||
return default
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _resolve_int_opt(val: Any) -> Optional[int]:
|
||||
"""Resolve an optional integer config value."""
|
||||
if val is None or val == "":
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
@@ -151,9 +239,10 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
self._health_stats["started_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Register event queue
|
||||
queue_resp = await self._api_call(
|
||||
queue_resp, _ = await self._api_call(
|
||||
"POST", "/api/v1/register",
|
||||
data={
|
||||
"event_types": '["message"]',
|
||||
@@ -170,15 +259,34 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
self._last_event_id = data.get("last_event_id", -1)
|
||||
self._bot_user_id = data.get("user_id")
|
||||
|
||||
# Resolve bot user_id if register endpoint didn't provide it
|
||||
# (common on some Zulip versions)
|
||||
if self._bot_user_id is None:
|
||||
await self._resolve_bot_user_id()
|
||||
|
||||
# Gen 2: Try to resolve @all-bots user ID dynamically
|
||||
await self._resolve_all_bots_user_id()
|
||||
|
||||
self._mark_connected()
|
||||
logger.info(
|
||||
"[%s] Connected to %s as %s (queue=%s, bot_id=%s)",
|
||||
"[%s] Connected to %s as %s (queue=%s, bot_id=%s, all_bots_id=%s)",
|
||||
self.name, self._site, self._email,
|
||||
self._queue_id, self._bot_user_id,
|
||||
self._queue_id, self._bot_user_id, self._all_bots_user_id,
|
||||
)
|
||||
|
||||
# Start poll loop
|
||||
self._poll_task = asyncio.create_task(self._poll_forever())
|
||||
|
||||
# Gen 2: Start background dedup cleanup task
|
||||
self._dedup_cleanup_task = asyncio.create_task(
|
||||
self._dedup_cleanup_forever()
|
||||
)
|
||||
|
||||
# Gen 3: Start periodic @all-bots refresh task
|
||||
self._all_bots_refresh_task = asyncio.create_task(
|
||||
self._all_bots_refresh_forever()
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -186,7 +294,26 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Zulip and cancel the poll loop."""
|
||||
"""Disconnect from Zulip and cancel all background tasks."""
|
||||
# Cancel dedup cleanup task
|
||||
if self._dedup_cleanup_task:
|
||||
self._dedup_cleanup_task.cancel()
|
||||
try:
|
||||
await self._dedup_cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._dedup_cleanup_task = None
|
||||
|
||||
# Cancel @all-bots refresh task
|
||||
if self._all_bots_refresh_task:
|
||||
self._all_bots_refresh_task.cancel()
|
||||
try:
|
||||
await self._all_bots_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._all_bots_refresh_task = None
|
||||
|
||||
# Cancel poll task
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
@@ -194,6 +321,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._poll_task = None
|
||||
|
||||
self._queue_id = None
|
||||
self._mark_disconnected()
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
@@ -213,14 +341,37 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
events = await self._fetch_events()
|
||||
self._health_stats["poll_count"] += 1
|
||||
self._health_stats["last_poll_at"] = datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
for event in events:
|
||||
try:
|
||||
await self._process_zulip_event(event)
|
||||
except Exception as e:
|
||||
# Gen 3: Malformed message resilience — catch per-event
|
||||
# failures so one bad message never kills the poll loop
|
||||
logger.warning(
|
||||
"[%s] Malformed event skipped: %s. "
|
||||
"Event id=%s",
|
||||
self.name, e,
|
||||
event.get("id", "unknown"),
|
||||
)
|
||||
self._health_stats["malformed_events"] = (
|
||||
self._health_stats.get("malformed_events", 0) + 1
|
||||
)
|
||||
backoff_idx = 0
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
self._health_stats["poll_errors"] += 1
|
||||
self._health_stats["last_error_at"] = datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
self._health_stats["last_error_msg"] = str(e)[:200]
|
||||
|
||||
err_str = str(e)
|
||||
if "BAD_EVENT_QUEUE_ID" in err_str or "queue_id" in err_str.lower():
|
||||
logger.info("[%s] Queue expired, re-registering...", self.name)
|
||||
@@ -235,11 +386,15 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
async def _fetch_events(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch events from the Zulip event queue."""
|
||||
"""Fetch events from the Zulip event queue.
|
||||
|
||||
Raises RuntimeError with BAD_EVENT_QUEUE_ID when the queue
|
||||
expires, so _poll_forever can reconnect.
|
||||
"""
|
||||
if not self._queue_id:
|
||||
return []
|
||||
|
||||
resp = await self._api_call(
|
||||
resp, raw_status = await self._api_call(
|
||||
"GET", "/api/v1/events",
|
||||
params={
|
||||
"queue_id": self._queue_id,
|
||||
@@ -247,6 +402,9 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"dont_block": "true",
|
||||
},
|
||||
)
|
||||
# Detect queue expiry from HTTP response
|
||||
if raw_status == 400:
|
||||
raise RuntimeError("BAD_EVENT_QUEUE_ID: queue expired")
|
||||
if not resp:
|
||||
return []
|
||||
|
||||
@@ -263,7 +421,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"""Re-register the event queue."""
|
||||
self._queue_id = None
|
||||
try:
|
||||
resp = await self._api_call(
|
||||
resp, _ = await self._api_call(
|
||||
"POST", "/api/v1/register",
|
||||
data={
|
||||
"event_types": '["message"]',
|
||||
@@ -274,12 +432,144 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
if resp:
|
||||
self._queue_id = resp.get("queue_id")
|
||||
self._last_event_id = resp.get("last_event_id", -1)
|
||||
self._health_stats["reconnects"] += 1
|
||||
logger.info("[%s] Reconnected, queue=%s", self.name, self._queue_id)
|
||||
except Exception as e:
|
||||
logger.error("[%s] Reconnect failed: %s", self.name, e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message processing — Zulip event → MessageEvent → Gateway
|
||||
# Gen 2: Background dedup maintenance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _dedup_cleanup_forever(self) -> None:
|
||||
"""Periodically purge stale entries from the dedup map.
|
||||
|
||||
This runs as a background task so the per-message _is_duplicate()
|
||||
path stays O(1) — no cleanup cost on every message.
|
||||
|
||||
Also triggers the health stats callback on each cycle if one is
|
||||
registered — natural tick for periodic monitoring.
|
||||
"""
|
||||
cycle = 0
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(DEDUP_CLEANUP_INTERVAL)
|
||||
self._prune_dedup_map()
|
||||
cycle += 1
|
||||
# Report health every N cycles based on callback interval
|
||||
if (
|
||||
self._health_callback
|
||||
and cycle * DEDUP_CLEANUP_INTERVAL
|
||||
>= self._health_callback_interval
|
||||
):
|
||||
await self._report_health_if_callback()
|
||||
cycle = 0
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] Dedup cleanup error (non-fatal)", self.name
|
||||
)
|
||||
|
||||
def _prune_dedup_map(self) -> None:
|
||||
"""Remove entries older than DEDUP_WINDOW from the dedup map."""
|
||||
now = time.time()
|
||||
cutoff = now - DEDUP_WINDOW
|
||||
before = len(self._seen_message_ids)
|
||||
self._seen_message_ids = {
|
||||
k: v for k, v in self._seen_message_ids.items() if v > cutoff
|
||||
}
|
||||
after = len(self._seen_message_ids)
|
||||
if before != after:
|
||||
logger.debug(
|
||||
"[%s] Dedup map pruned: %d -> %d entries",
|
||||
self.name, before, after,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 3: Periodic @all-bots refresh
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _all_bots_refresh_forever(self) -> None:
|
||||
"""Periodically re-resolve the @all-bots user ID.
|
||||
|
||||
Runs every ALL_BOTS_REFRESH_INTERVAL (1 hour by default).
|
||||
Handles the case where the @all-bots user is created or
|
||||
recreated after initial connection.
|
||||
"""
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(ALL_BOTS_REFRESH_INTERVAL)
|
||||
await self._resolve_all_bots_user_id()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] @all-bots refresh error (non-fatal)", self.name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 5: Bot user ID resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_bot_user_id(self) -> None:
|
||||
"""Fetch the bot's own user ID from /api/v1/users/me.
|
||||
|
||||
The /register endpoint does not always return user_id on
|
||||
all Zulip server versions. This ensures @mention detection
|
||||
works by resolving it from the identity endpoint.
|
||||
"""
|
||||
try:
|
||||
resp, _ = await self._api_call("GET", "/api/v1/users/me")
|
||||
if resp:
|
||||
uid = resp.get("user_id")
|
||||
if uid is not None:
|
||||
self._bot_user_id = int(uid)
|
||||
logger.info(
|
||||
"[%s] Resolved bot user_id=%s from /users/me",
|
||||
self.name, uid,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Could not resolve bot user_id: %s", self.name, e
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 2: Dynamic @all-bots resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_all_bots_user_id(self) -> None:
|
||||
"""Try to resolve the @all-bots user ID from the Zulip server.
|
||||
|
||||
Falls back to the configured or default value if resolution fails.
|
||||
"""
|
||||
try:
|
||||
resp, _ = await self._api_call("GET", "/api/v1/users")
|
||||
if not resp:
|
||||
return
|
||||
members = resp.get("members", [])
|
||||
for member in members:
|
||||
email = member.get("email", "")
|
||||
# @all-bots typically has an email pattern like all-bots@...
|
||||
if "all-bots" in email.lower():
|
||||
uid = member.get("user_id")
|
||||
if uid is not None:
|
||||
self._all_bots_user_id = int(uid)
|
||||
self._health_stats["all_bots_refreshes"] += 1
|
||||
logger.info(
|
||||
"[%s] Resolved @all-bots user_id=%s from %s",
|
||||
self.name, uid, email,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Could not resolve @all-bots ID dynamically: %s. "
|
||||
"Using configured default %s.",
|
||||
self.name, e, self._all_bots_user_id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message processing — Zulip event -> MessageEvent -> Gateway
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_zulip_event(self, event: Dict[str, Any]) -> None:
|
||||
@@ -295,11 +585,21 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
if sender_email == self._bot_email:
|
||||
return
|
||||
|
||||
# Deduplication
|
||||
# Gen 2: Check for self-test response
|
||||
if self._selftest_future and not self._selftest_future.done():
|
||||
# Detect if this is a response to our self-test
|
||||
self._selftest_future.set_result(True)
|
||||
return
|
||||
|
||||
# Deduplication (O(1) — no cleanup on this path)
|
||||
msg_id = f"{event.get('id', '')}:{msg.get('id', '')}"
|
||||
if self._is_duplicate(msg_id):
|
||||
return
|
||||
|
||||
# Update health stats
|
||||
self._health_stats["messages_received"] += 1
|
||||
self._health_stats["last_message_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Determine if this message targets this bot
|
||||
mentioned_users = msg.get("mentioned_users", []) or []
|
||||
mentioned_ids = [u.get("user_id") for u in mentioned_users if isinstance(u, dict)]
|
||||
@@ -309,6 +609,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
|
||||
# DM-first: process all private messages
|
||||
if is_dm:
|
||||
self._health_stats["dms_routed"] += 1
|
||||
logger.info("[%s] DM from %s: %.60s", self.name, sender_name, content)
|
||||
await self._route_message(
|
||||
text=content,
|
||||
@@ -325,6 +626,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
|
||||
# Stream: only respond to @mentions and @all-bots
|
||||
if msg_type == "stream" and (is_direct_mention or is_all_bots):
|
||||
self._health_stats["mentions_routed"] += 1
|
||||
stream_name = msg.get("display_recipient", "unknown")
|
||||
topic = msg.get("subject", "general")
|
||||
logger.info(
|
||||
@@ -460,28 +762,24 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"[%s] Sent placeholder msg=%s for %s",
|
||||
self.name, msg_id, chat_id,
|
||||
)
|
||||
self._health_stats["send_count"] += 1
|
||||
return SendResult(
|
||||
success=True,
|
||||
platform="zulip",
|
||||
chat_id=chat_id,
|
||||
message_id=msg_id,
|
||||
metadata={"placeholder": True},
|
||||
)
|
||||
|
||||
# Send actual content
|
||||
result = await self._send_api_call(payload)
|
||||
if result and result.get("id"):
|
||||
self._health_stats["send_count"] += 1
|
||||
return SendResult(
|
||||
success=True,
|
||||
platform="zulip",
|
||||
chat_id=chat_id,
|
||||
message_id=str(result["id"]),
|
||||
)
|
||||
|
||||
self._health_stats["send_errors"] += 1
|
||||
return SendResult(
|
||||
success=False,
|
||||
platform="zulip",
|
||||
chat_id=chat_id,
|
||||
error="Failed to send message",
|
||||
)
|
||||
|
||||
@@ -499,7 +797,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"content": truncated,
|
||||
}
|
||||
try:
|
||||
resp = await self._api_call("PATCH", "/api/v1/messages", data=payload)
|
||||
resp, _ = await self._api_call("PATCH", "/api/v1/messages", data=payload)
|
||||
return resp is not None
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Edit message %s error: %s", self.name, message_id, e)
|
||||
@@ -515,6 +813,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"POST", "/api/v1/typing",
|
||||
data={"to": user_ids, "op": "start"},
|
||||
)
|
||||
# typing indicator failure is non-critical
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
@@ -528,9 +827,17 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
"POST", "/api/v1/typing",
|
||||
data={"to": user_ids, "op": "stop"},
|
||||
)
|
||||
# typing indicator failure is non-critical
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> dict:
|
||||
"""Return basic info about a Zulip chat."""
|
||||
if ":" in chat_id:
|
||||
parts = chat_id.split(":", 1)
|
||||
return {"name": parts[0], "type": "channel", "topic": parts[1]}
|
||||
return {"name": chat_id, "type": "dm"}
|
||||
|
||||
async def delete_message(
|
||||
self, chat_id: str, message_id: str, metadata: Optional[Dict] = None
|
||||
) -> bool:
|
||||
@@ -539,6 +846,192 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
# Send an empty edit instead
|
||||
return await self.edit_message(chat_id, message_id, "*deleted*")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 2: Self-test diagnostics
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def selftest(self) -> Dict[str, Any]:
|
||||
"""Run a self-test to verify adapter health.
|
||||
|
||||
Checks:
|
||||
1. Connection state (queue registered, HTTP client alive)
|
||||
2. Bot identity resolved (bot_user_id is set)
|
||||
3. Queue polling active (poll_task is running)
|
||||
4. Echo-loop prevention configured (bot_email matches registered email)
|
||||
5. Subscription to primary stream (if reachable)
|
||||
6. @all-bots ID resolved
|
||||
7. Dedup maintenance task running
|
||||
|
||||
Returns a dict with pass/fail for each check and an overall verdict.
|
||||
"""
|
||||
checks = {}
|
||||
|
||||
# 1. Connection state
|
||||
checks["connected"] = {
|
||||
"status": self._connected,
|
||||
"detail": "Queue registered and connected"
|
||||
if self._connected
|
||||
else "Not connected to Zulip",
|
||||
}
|
||||
|
||||
# 2. Queue registered
|
||||
checks["queue_registered"] = {
|
||||
"status": self._queue_id is not None,
|
||||
"detail": f"Queue: {self._queue_id}"
|
||||
if self._queue_id
|
||||
else "No queue registered",
|
||||
}
|
||||
|
||||
# 3. HTTP client alive
|
||||
checks["http_client"] = {
|
||||
"status": self._http_client is not None and not self._http_client.is_closed,
|
||||
"detail": "HTTP client active"
|
||||
if self._http_client and not self._http_client.is_closed
|
||||
else "HTTP client unavailable",
|
||||
}
|
||||
|
||||
# 4. Bot identity
|
||||
checks["bot_identity"] = {
|
||||
"status": self._bot_user_id is not None,
|
||||
"detail": f"Bot user_id: {self._bot_user_id}"
|
||||
if self._bot_user_id
|
||||
else "Bot user_id not resolved",
|
||||
}
|
||||
|
||||
# 5. Poll loop active
|
||||
checks["poll_loop"] = {
|
||||
"status": self._poll_task is not None and not self._poll_task.done(),
|
||||
"detail": "Poll loop running"
|
||||
if self._poll_task and not self._poll_task.done()
|
||||
else "Poll loop not active",
|
||||
}
|
||||
|
||||
# 6. Dedup cleanup active
|
||||
checks["dedup_cleanup"] = {
|
||||
"status": self._dedup_cleanup_task is not None
|
||||
and not self._dedup_cleanup_task.done(),
|
||||
"detail": "Dedup maintenance running"
|
||||
if self._dedup_cleanup_task and not self._dedup_cleanup_task.done()
|
||||
else "Dedup maintenance not active",
|
||||
}
|
||||
|
||||
# 7. Echo-loop prevention
|
||||
checks["echo_prevention"] = {
|
||||
"status": bool(self._bot_email),
|
||||
"detail": f"Bot email: {self._bot_email}",
|
||||
}
|
||||
|
||||
# 8. @all-bots configured
|
||||
checks["all_bots_configured"] = {
|
||||
"status": self._all_bots_user_id is not None,
|
||||
"detail": f"@all-bots user_id: {self._all_bots_user_id}",
|
||||
}
|
||||
|
||||
# Overall verdict
|
||||
critical = ["connected", "queue_registered", "http_client", "poll_loop"]
|
||||
passed = sum(1 for c in checks.values() if c["status"])
|
||||
critical_passed = sum(1 for k in critical if checks.get(k, {}).get("status"))
|
||||
failed = [k for k, v in checks.items() if not v["status"]]
|
||||
|
||||
if critical_passed == len(critical) and passed == len(checks):
|
||||
verdict = "healthy"
|
||||
elif critical_passed < len(critical):
|
||||
verdict = "critical_failure"
|
||||
else:
|
||||
verdict = "degraded"
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"checks": checks,
|
||||
"summary": {
|
||||
"total": len(checks),
|
||||
"passed": passed,
|
||||
"failed": len(failed),
|
||||
"failed_checks": failed,
|
||||
"critical_passed": critical_passed,
|
||||
"critical_total": len(critical),
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 3: Health stats callback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_health_callback(
|
||||
self,
|
||||
callback,
|
||||
interval: int = 600,
|
||||
) -> None:
|
||||
"""Register a callback for periodic health stats reporting.
|
||||
|
||||
The callback is called with health stats dict every `interval`
|
||||
seconds. Can be used by the Hermes Gateway to log health to
|
||||
RA-H OS knowledge graph or external monitoring.
|
||||
|
||||
Args:
|
||||
callback: Async callable receiving health stats dict
|
||||
interval: Seconds between calls (default 600 = 10 min)
|
||||
"""
|
||||
self._health_callback = callback
|
||||
self._health_callback_interval = interval
|
||||
|
||||
async def _report_health_if_callback(self) -> None:
|
||||
"""Call the health stats callback if one is registered."""
|
||||
if self._health_callback:
|
||||
try:
|
||||
stats = await self.get_health_stats()
|
||||
await self._health_callback(stats)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Health callback error: %s", self.name, e
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gen 2: Health stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_health_stats(self) -> Dict[str, Any]:
|
||||
"""Return health and performance statistics.
|
||||
|
||||
Returns a snapshot of:
|
||||
- Uptime
|
||||
- Poll counts, errors, reconnects
|
||||
- Message counts (total, DM, mention)
|
||||
- Send counts and errors
|
||||
- Dedup map size
|
||||
- Timestamps of last activity
|
||||
|
||||
Suitable for periodic logging to RA-H OS knowledge graph.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
uptime = None
|
||||
if self._health_stats.get("started_at"):
|
||||
try:
|
||||
started = datetime.fromisoformat(self._health_stats["started_at"])
|
||||
uptime_seconds = (now - started).total_seconds()
|
||||
uptime = f"{uptime_seconds:.0f}s"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
stats = dict(self._health_stats)
|
||||
stats["uptime"] = uptime
|
||||
stats["dedup_map_size"] = len(self._seen_message_ids)
|
||||
stats["queue_id"] = self._queue_id
|
||||
stats["bot_user_id"] = self._bot_user_id
|
||||
stats["all_bots_user_id"] = self._all_bots_user_id
|
||||
stats["connected"] = self._connected
|
||||
stats["checked_at"] = now.isoformat()
|
||||
|
||||
# Compute error rate
|
||||
total_polls = stats.get("poll_count", 0) or 1
|
||||
stats["error_rate"] = round(
|
||||
stats.get("poll_errors", 0) / total_polls, 4
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Zulip API helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -549,10 +1042,15 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
path: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Make an API call to Zulip."""
|
||||
) -> Tuple[Optional[Dict], int]:
|
||||
"""Make an API call to Zulip.
|
||||
|
||||
Returns (response_json, status_code). status_code is useful for
|
||||
callers that need to detect specific HTTP errors like 400
|
||||
(BAD_EVENT_QUEUE_ID). Returns (None, 0) on transport errors.
|
||||
"""
|
||||
if not self._http_client:
|
||||
return None
|
||||
return None, 0
|
||||
|
||||
url = f"{self._site}{path}"
|
||||
headers = {
|
||||
@@ -573,7 +1071,7 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
url, data=data, headers=headers,
|
||||
)
|
||||
else:
|
||||
return None
|
||||
return None, 0
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.warning(
|
||||
@@ -581,20 +1079,21 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
self.name, method, path,
|
||||
response.status_code, response.text[:200],
|
||||
)
|
||||
return None
|
||||
return None, response.status_code
|
||||
|
||||
return response.json()
|
||||
return response.json(), response.status_code
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.debug("[%s] Timeout on %s %s", self.name, method, path)
|
||||
return None
|
||||
return None, 0
|
||||
except Exception as e:
|
||||
logger.warning("[%s] API error %s %s: %s", self.name, method, path, e)
|
||||
return None
|
||||
return None, 0
|
||||
|
||||
async def _send_api_call(self, payload: Dict) -> Optional[Dict]:
|
||||
"""Send a message to Zulip."""
|
||||
return await self._api_call("POST", "/api/v1/messages", data=payload)
|
||||
result, _ = await self._api_call("POST", "/api/v1/messages", data=payload)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utilities
|
||||
@@ -606,24 +1105,28 @@ class ZulipAdapter(BasePlatformAdapter):
|
||||
return PLACEHOLDERS[idx]
|
||||
|
||||
def _is_duplicate(self, msg_id: str) -> bool:
|
||||
"""Deduplication using message IDs and time window."""
|
||||
now = time.time()
|
||||
window = 300 # 5 minutes
|
||||
max_size = 1000
|
||||
|
||||
# Clean old entries
|
||||
if len(self._seen_message_ids) > max_size:
|
||||
cutoff = now - window
|
||||
self._seen_message_ids = {
|
||||
k: v for k, v in self._seen_message_ids.items() if v > cutoff
|
||||
}
|
||||
"""Deduplication using message IDs — O(1) lookup, no cleanup.
|
||||
|
||||
Cleanup runs in a separate background task (_dedup_cleanup_forever)
|
||||
so this path stays fast.
|
||||
"""
|
||||
if msg_id in self._seen_message_ids:
|
||||
return True
|
||||
|
||||
self._seen_message_ids[msg_id] = now
|
||||
self._seen_message_ids[msg_id] = time.time()
|
||||
return False
|
||||
|
||||
def get_all_bots_user_id(self) -> int:
|
||||
"""Return the current @all-bots user ID.
|
||||
|
||||
This is used by the zulip-mention-reliability contract for
|
||||
cross-contract verification.
|
||||
"""
|
||||
return self._all_bots_user_id
|
||||
|
||||
def get_bot_user_id(self) -> Optional[int]:
|
||||
"""Return the bot's own user ID for identity checks."""
|
||||
return self._bot_user_id
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin registration helpers
|
||||
|
||||
+181
-79
@@ -1,32 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh GitOps deployment for zulip-platform-plugins
|
||||
# Usage: ./deploy.sh <tag|branch> [--ct=<agent>] [--dry-run]
|
||||
# ./deploy.sh v1.0.0 # Deploy to all 6 CTs
|
||||
# ./deploy.sh main # Deploy latest (staging only!)
|
||||
# ./deploy.sh --ct=tanko v1.0.0 # Deploy to single CT
|
||||
# ./deploy.sh --dry-run v1.0.0 # Preview deployment without making changes
|
||||
# deploy.sh — GitOps deployment for zulip-platform-plugins
|
||||
#
|
||||
# Supports two deployment modes:
|
||||
# LEGACY: /opt/hermes-zulip-plugin/ + systemctl restart zulip-plugin
|
||||
# NATIVE: ~/.hermes/plugins/platforms/zulip/ + hermes gateway restart
|
||||
#
|
||||
# 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
|
||||
|
||||
DEPLOY_LOG="deploy.log"
|
||||
TAG=""
|
||||
SINGLE_CT=""
|
||||
DRY_RUN=false
|
||||
DEPLOY_MODE="native" # default to new Hermes native plugin
|
||||
|
||||
# Parse args
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--ct=*) SINGLE_CT="${arg#--ct=}" ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--mode=*) DEPLOY_MODE="${arg#--mode=}" ;;
|
||||
*) TAG="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--dry-run]"
|
||||
echo "Usage: $0 <tag|branch> [--ct=<agent>] [--mode=native|legacy] [--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
|
||||
fi
|
||||
|
||||
# Agent CT registry (must match CONTEXT.md)
|
||||
if [[ "$DEPLOY_MODE" != "native" && "$DEPLOY_MODE" != "legacy" ]]; then
|
||||
echo "ERROR: --mode must be 'native' or 'legacy'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Agent Registry (must match docs/CONTEXT.md) ──────────────────────
|
||||
declare -A AGENTS=(
|
||||
["tanko"]="amdpve CT 112"
|
||||
["mumuni"]="minipve CT 114"
|
||||
@@ -36,32 +60,31 @@ declare -A AGENTS=(
|
||||
["abiba"]="amdpve CT 100"
|
||||
)
|
||||
|
||||
# Platform paths per agent type
|
||||
declare -A PLUGIN_PATHS=(
|
||||
# Native Hermes plugin path (~/.hermes/plugins/platforms/zulip/)
|
||||
NATIVE_PLUGIN_SRC="plugins/platforms/zulip"
|
||||
|
||||
# Legacy paths (DEPRECATED — systemd zulip-plugin service)
|
||||
declare -A LEGACY_PATHS=(
|
||||
["tanko"]="/opt/hermes-zulip-plugin"
|
||||
["mumuni"]="/opt/hermes-zulip-plugin"
|
||||
["koonimo"]="/opt/hermes-zulip-plugin"
|
||||
["koby\"]="/opt/hermes-zulip-plugin"
|
||||
["kagentz\"]="/opt/agent-zero-plugin"
|
||||
["abiba\"]="/root/.pi/agent/extensions/zulip.ts"
|
||||
["koby"]="/opt/hermes-zulip-plugin"
|
||||
["kagentz"]="/opt/agent-zero-plugin"
|
||||
["abiba"]="/root/.pi/agent/extensions/zulip.ts"
|
||||
)
|
||||
|
||||
# Correcting the backslash artifacts from the original file
|
||||
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=(
|
||||
# Service names for legacy mode
|
||||
declare -A LEGACY_SERVICES=(
|
||||
["tanko"]="zulip-plugin"
|
||||
["mumuni"]="zulip-plugin"
|
||||
["koonimo"]="zulip-plugin"
|
||||
["koby"]="zulip-plugin"
|
||||
["kagentz"]="zulip-plugin"
|
||||
["abiba"]="pi" # pi reload, not systemctl
|
||||
["abiba"]="pi"
|
||||
)
|
||||
|
||||
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
||||
HEALTH_PORT=9200
|
||||
GITEA_REPO="https://git.sysloggh.net/SyslogSolution/zulip-platform-plugins.git"
|
||||
|
||||
log() {
|
||||
local prefix=""
|
||||
@@ -69,90 +92,169 @@ log() {
|
||||
echo "${prefix}[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"
|
||||
}
|
||||
|
||||
deploy_agent() {
|
||||
# ── Deployment Functions ─────────────────────────────────────────────
|
||||
|
||||
deploy_native() {
|
||||
local agent="$1"
|
||||
local ct_info="${AGENTS[$agent]}"
|
||||
local plugin_path="${PLUGIN_PATHS[$agent]}"
|
||||
local service="${SERVICE_NAMES[$agent]}"
|
||||
local plugin_dir="$HOME/.hermes/plugins/platforms/zulip"
|
||||
|
||||
log "=== Deploying $agent ($ct_info) @ $TAG ==="
|
||||
log "Path: $plugin_path"
|
||||
log "📦 Deploying $agent: native Hermes plugin -> $plugin_dir"
|
||||
|
||||
# 1. Git pull & checkout tag
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
cd "$plugin_path" || { log "ERROR: $agent path $plugin_path not found"; return 1; }
|
||||
git fetch --tags origin
|
||||
git checkout "$TAG"
|
||||
else
|
||||
log "Skipping Git checkout (Dry Run)"
|
||||
fi
|
||||
log "$agent: checked out $TAG"
|
||||
|
||||
# 2. Install dependencies (platform-specific)
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
case "$agent" in
|
||||
tanko|mumuni|koonimo|koby)
|
||||
pip install -r requirements.txt --quiet
|
||||
;;
|
||||
kagentz)
|
||||
pip install -r requirements.txt --quiet
|
||||
;;
|
||||
abiba)
|
||||
log "$agent: pi extension skipping pip install"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log "Skipping dependency installation (Dry Run)"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log " Would copy $NATIVE_PLUGIN_SRC/{adapter.py,__init__.py,plugin.yaml} -> $plugin_dir/"
|
||||
log " Would run: hermes gateway restart"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 3. Restart service
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
case "$agent" in
|
||||
abiba)
|
||||
log "$agent: triggering /reload"
|
||||
;;
|
||||
*)
|
||||
systemctl restart "$service"
|
||||
log "$agent: restarted $service"
|
||||
;;
|
||||
esac
|
||||
# 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 "Skipping service restart (Dry Run)"
|
||||
log " ⚠️ 'hermes' command not found — manual restart needed"
|
||||
log " Run: hermes gateway restart"
|
||||
fi
|
||||
|
||||
# 4. Health check
|
||||
# Health check
|
||||
log " Waiting for service to stabilize..."
|
||||
sleep 5
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||
log "OK: $agent health check passed"
|
||||
log " ✅ Health check passed (port $HEALTH_PORT)"
|
||||
else
|
||||
log "ERROR: $agent health check failed check logs"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log "Skipping health check (Dry Run)"
|
||||
log " ⚠️ Health check on port $HEALTH_PORT not responding"
|
||||
log " Check Gateway logs for plugin load errors"
|
||||
fi
|
||||
|
||||
log "✅ $agent: native deployment complete"
|
||||
}
|
||||
|
||||
# --- Main ---\
|
||||
log "Deploy started target: $TAG (Dry Run: $DRY_RUN)"
|
||||
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 checkout "$TAG"
|
||||
|
||||
log " Checked out $TAG in $plugin_path"
|
||||
|
||||
# Install deps
|
||||
if [[ -f "requirements.txt" ]]; then
|
||||
pip install -r requirements.txt --quiet
|
||||
log " Dependencies installed"
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
if systemctl list-units --full -all 2>/dev/null | grep -q "$service"; then
|
||||
systemctl restart "$service"
|
||||
log " Restarted $service"
|
||||
else
|
||||
log " ⚠️ Service $service not found — manual restart needed"
|
||||
fi
|
||||
|
||||
# Health check
|
||||
sleep 5
|
||||
if curl -sf "http://localhost:$HEALTH_PORT/health" > /dev/null 2>&1; then
|
||||
log " ✅ Health check passed"
|
||||
else
|
||||
log " ⚠️ 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
|
||||
fi
|
||||
fi
|
||||
|
||||
log " ✅ $agent verification complete"
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
log "🚀 Deploy started — tag: $TAG, mode: $DEPLOY_MODE, dry-run: $DRY_RUN"
|
||||
|
||||
if [[ -n "$SINGLE_CT" ]]; then
|
||||
deploy_agent "$SINGLE_CT"
|
||||
if [[ -z "${AGENTS[$SINGLE_CT]:-}" ]]; then
|
||||
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
|
||||
FAILED=""
|
||||
for agent in tanko mumuni koonimo koby kagentz abiba; do
|
||||
if ! deploy_agent "$agent"; then
|
||||
for agent in "${!AGENTS[@]}"; do
|
||||
echo ""
|
||||
if [[ "$DEPLOY_MODE" == "native" ]]; then
|
||||
if deploy_native "$agent"; then
|
||||
verify_deployment "$agent" || FAILED="$FAILED $agent"
|
||||
else
|
||||
FAILED="$FAILED $agent"
|
||||
fi
|
||||
else
|
||||
if deploy_legacy "$agent"; then
|
||||
verify_deployment "$agent" || FAILED="$FAILED $agent"
|
||||
else
|
||||
FAILED="$FAILED $agent"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
log "=== Deploy complete ==="
|
||||
if [[ -n "$FAILED" ]]; then
|
||||
log "FAILED:$FAILED"
|
||||
log "❌ FAILED:$FAILED"
|
||||
log " Run rollback: ./scripts/rollback.sh <previous-tag>"
|
||||
exit 1
|
||||
fi
|
||||
log "All 6 agents deployed successfully."
|
||||
log "✅ All agents deployed successfully."
|
||||
log " Next: monitor #agent-hub for agent responses"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user